Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/targets.js
alexei.dolgolyov 00c9ad3a86 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>
2026-03-17 02:03:07 +03:00

1417 lines
67 KiB
JavaScript

/**
* Targets tab — combined view of devices, LED targets, KC targets, pattern templates.
*/
import {
apiKey,
_targetEditorDevices, set_targetEditorDevices,
_deviceBrightnessCache,
kcWebSockets,
ledPreviewWebSockets,
_cachedValueSources, valueSourcesCache,
streamsCache, audioSourcesCache, syncClocksCache,
colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js';
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, formatRelativeTime } from './devices.js';
import { _splitOpenrgbZone } from './device-discovery.js';
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
import {
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW,
ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP,
ICON_WARNING, ICON_PALETTE, ICON_WRENCH, ICON_TEMPLATE,
} from '../core/icons.js';
import { EntitySelect } from '../core/entity-palette.js';
import { IconSelect } from '../core/icon-select.js';
import * as P from '../core/icon-paths.js';
import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
import { createFpsSparkline } from '../core/chart-utils.js';
import { CardSection } from '../core/card-sections.js';
import { TreeNav } from '../core/tree-nav.js';
import { updateSubTabHash, updateTabBadge } from './tabs.js';
// createPatternTemplateCard is imported via window.* to avoid circular deps
// (pattern-templates.js calls window.loadTargetsTab)
// ── Card section instances ──
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id', emptyKey: 'section.empty.devices' });
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` });
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` });
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates' });
// Re-render targets tab when language changes (only if tab is active)
document.addEventListener('languageChanged', () => {
if (apiKey && localStorage.getItem('activeTab') === 'targets') loadTargetsTab();
});
// --- FPS sparkline history and chart instances for target cards ---
const _TARGET_MAX_FPS_SAMPLES = 30;
const _targetFpsHistory = {}; // fps_actual (rolling avg)
const _targetFpsCurrentHistory = {}; // fps_current (sends/sec)
const _targetFpsCharts = {};
function _pushTargetFps(targetId, actual, current) {
if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = [];
const h = _targetFpsHistory[targetId];
h.push(actual);
if (h.length > _TARGET_MAX_FPS_SAMPLES) h.shift();
if (!_targetFpsCurrentHistory[targetId]) _targetFpsCurrentHistory[targetId] = [];
const c = _targetFpsCurrentHistory[targetId];
c.push(current);
if (c.length > _TARGET_MAX_FPS_SAMPLES) c.shift();
}
function _createTargetFpsChart(canvasId, actualHistory, currentHistory, fpsTarget, maxHwFps) {
return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget, { maxHwFps });
}
function _updateTargetFpsChart(targetId, fpsTarget) {
const chart = _targetFpsCharts[targetId];
if (!chart) return;
const actualH = _targetFpsHistory[targetId] || [];
const currentH = _targetFpsCurrentHistory[targetId] || [];
// Mutate in-place to avoid array copies
const ds0 = chart.data.datasets[0].data;
ds0.length = 0;
ds0.push(...actualH);
const ds1 = chart.data.datasets[1].data;
ds1.length = 0;
ds1.push(...currentH);
while (chart.data.labels.length < ds0.length) chart.data.labels.push('');
chart.data.labels.length = ds0.length;
chart.options.scales.y.max = fpsTarget * 1.15;
chart.update('none');
}
// --- Editor state ---
let _editorCssSources = []; // populated when editor opens
let _targetTagsInput = null;
class TargetEditorModal extends Modal {
constructor() {
super('target-editor-modal');
}
snapshotValues() {
return {
name: document.getElementById('target-editor-name').value,
device: document.getElementById('target-editor-device').value,
protocol: document.getElementById('target-editor-protocol').value,
css_source: document.getElementById('target-editor-css-source').value,
brightness_vs: document.getElementById('target-editor-brightness-vs').value,
brightness_threshold: document.getElementById('target-editor-brightness-threshold').value,
fps: document.getElementById('target-editor-fps').value,
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
adaptive_fps: document.getElementById('target-editor-adaptive-fps').checked,
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
};
}
}
const targetEditorModal = new TargetEditorModal();
function _protocolBadge(device, target) {
const dt = device?.device_type;
if (!dt || dt === 'wled') {
const proto = target.protocol === 'http' ? 'HTTP' : 'DDP';
return `${target.protocol === 'http' ? ICON_GLOBE : ICON_RADIO} ${proto}`;
}
const map = {
openrgb: [ICON_PALETTE, 'OpenRGB SDK'],
adalight: [ICON_PLUG, t('targets.protocol.serial')],
ambiled: [ICON_PLUG, t('targets.protocol.serial')],
mqtt: [ICON_GLOBE, 'MQTT'],
ws: [ICON_GLOBE, 'WebSocket'],
mock: [ICON_WRENCH, 'Mock'],
};
const [icon, label] = map[dt] || [ICON_PLUG, dt];
return `${icon} ${label}`;
}
let _targetNameManuallyEdited = false;
function _autoGenerateTargetName() {
if (_targetNameManuallyEdited) return;
if (document.getElementById('target-editor-id').value) return;
const deviceSelect = document.getElementById('target-editor-device');
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
const cssSelect = document.getElementById('target-editor-css-source');
const cssName = cssSelect?.selectedOptions[0]?.dataset?.name || '';
if (!deviceName || !cssName) return;
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
}
function _updateFpsRecommendation() {
const el = document.getElementById('target-editor-fps-rec');
const deviceSelect = document.getElementById('target-editor-device');
const device = _targetEditorDevices.find(d => d.id === deviceSelect.value);
if (!device || !device.led_count) {
el.style.display = 'none';
return;
}
const fps = _computeMaxFps(device.baud_rate, device.led_count, device.device_type);
if (fps !== null) {
el.textContent = t('targets.fps.rec', { fps, leds: device.led_count });
el.style.display = '';
} else {
el.style.display = 'none';
}
}
function _updateDeviceInfo() {
const deviceSelect = document.getElementById('target-editor-device');
const el = document.getElementById('target-editor-device-info');
const device = _targetEditorDevices.find(d => d.id === deviceSelect.value);
if (device && device.led_count) {
el.textContent = `${device.led_count} LEDs`;
el.style.display = '';
} else {
el.style.display = 'none';
}
}
function _updateKeepaliveVisibility() {
const deviceSelect = document.getElementById('target-editor-device');
const keepaliveGroup = document.getElementById('target-editor-keepalive-group');
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
const caps = selectedDevice?.capabilities || [];
keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none';
}
function _updateSpecificSettingsVisibility() {
const deviceSelect = document.getElementById('target-editor-device');
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
const isWled = !selectedDevice || selectedDevice.device_type === 'wled';
// Hide WLED-only controls (protocol + keepalive) for non-WLED devices
const protocolGroup = document.getElementById('target-editor-protocol-group');
if (protocolGroup) protocolGroup.style.display = isWled ? '' : 'none';
// keepalive is controlled further by _updateKeepaliveVisibility
const keepaliveGroup = document.getElementById('target-editor-keepalive-group');
if (keepaliveGroup && !isWled) keepaliveGroup.style.display = 'none';
}
function _updateBrightnessThresholdVisibility() {
// Always visible — threshold considers both brightness source and pixel content
document.getElementById('target-editor-brightness-threshold-group').style.display = '';
}
// ── EntitySelect instances for target editor ──
let _deviceEntitySelect = null;
let _cssEntitySelect = null;
let _brightnessVsEntitySelect = null;
let _protocolIconSelect = null;
function _populateCssDropdown(selectedId = '') {
const select = document.getElementById('target-editor-css-source');
select.innerHTML = _editorCssSources.map(s =>
`<option value="${s.id}" data-name="${escapeHtml(s.name)}" ${s.id === selectedId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
}
function _populateBrightnessVsDropdown(selectedId = '') {
const select = document.getElementById('target-editor-brightness-vs');
let html = `<option value="">${t('targets.brightness_vs.none')}</option>`;
_cachedValueSources.forEach(vs => {
html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${escapeHtml(vs.name)}</option>`;
});
select.innerHTML = html;
}
function _ensureTargetEntitySelects() {
// Device
if (_deviceEntitySelect) _deviceEntitySelect.destroy();
_deviceEntitySelect = new EntitySelect({
target: document.getElementById('target-editor-device'),
getItems: () => _targetEditorDevices.map(d => ({
value: d.id,
label: d.name,
icon: getDeviceTypeIcon(d.device_type),
desc: (d.device_type || 'wled').toUpperCase() + (d.url ? ` · ${d.url.replace(/^https?:\/\//, '')}` : ''),
})),
placeholder: t('palette.search'),
});
// CSS source
if (_cssEntitySelect) _cssEntitySelect.destroy();
_cssEntitySelect = new EntitySelect({
target: document.getElementById('target-editor-css-source'),
getItems: () => _editorCssSources.map(s => ({
value: s.id,
label: s.name,
icon: getColorStripIcon(s.source_type),
desc: s.source_type,
})),
placeholder: t('palette.search'),
});
// Brightness value source
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
_brightnessVsEntitySelect = new EntitySelect({
target: document.getElementById('target-editor-brightness-vs'),
getItems: () => _cachedValueSources.map(vs => ({
value: vs.id,
label: vs.name,
icon: getValueSourceIcon(vs.source_type),
desc: vs.source_type,
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('targets.brightness_vs.none'),
});
}
const _pIcon = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
function _ensureProtocolIconSelect() {
const sel = document.getElementById('target-editor-protocol');
if (!sel) return;
const items = [
{ value: 'ddp', icon: _pIcon(P.radio), label: t('targets.protocol.ddp'), desc: t('targets.protocol.ddp.desc') },
{ value: 'http', icon: _pIcon(P.globe), label: t('targets.protocol.http'), desc: t('targets.protocol.http.desc') },
];
if (_protocolIconSelect) { _protocolIconSelect.updateItems(items); return; }
_protocolIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
export async function showTargetEditor(targetId = null, cloneData = null) {
try {
// Load devices, CSS sources, and value sources for dropdowns
const [devices, cssSources] = await Promise.all([
devicesCache.fetch().catch(() => []),
colorStripSourcesCache.fetch().catch(() => []),
valueSourcesCache.fetch(),
]);
set_targetEditorDevices(devices);
_editorCssSources = cssSources;
// Populate device select
const deviceSelect = document.getElementById('target-editor-device');
deviceSelect.innerHTML = '';
devices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.id;
opt.dataset.name = d.name;
const shortUrl = d.url && d.url.startsWith('http') ? d.url.replace(/^https?:\/\//, '') : '';
const devType = (d.device_type || 'wled').toUpperCase();
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
deviceSelect.appendChild(opt);
});
let _editorTags = [];
if (targetId) {
// Editing existing target
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();
_editorTags = target.tags || [];
document.getElementById('target-editor-id').value = target.id;
document.getElementById('target-editor-name').value = target.name;
deviceSelect.value = target.device_id || '';
const fps = target.fps ?? 30;
document.getElementById('target-editor-fps').value = fps;
document.getElementById('target-editor-fps-value').textContent = fps;
document.getElementById('target-editor-keepalive-interval').value = target.keepalive_interval ?? 1.0;
document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.0;
document.getElementById('target-editor-title').innerHTML = `${ICON_TARGET_ICON} ${t('targets.edit')}`;
const thresh = target.min_brightness_threshold ?? 0;
document.getElementById('target-editor-brightness-threshold').value = thresh;
document.getElementById('target-editor-brightness-threshold-value').textContent = thresh;
document.getElementById('target-editor-adaptive-fps').checked = target.adaptive_fps ?? false;
document.getElementById('target-editor-protocol').value = target.protocol || 'ddp';
_populateCssDropdown(target.color_strip_source_id || '');
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
} else if (cloneData) {
// Cloning — create mode but pre-filled from clone data
_editorTags = cloneData.tags || [];
document.getElementById('target-editor-id').value = '';
document.getElementById('target-editor-name').value = (cloneData.name || '') + ' (Copy)';
deviceSelect.value = cloneData.device_id || '';
const fps = cloneData.fps ?? 30;
document.getElementById('target-editor-fps').value = fps;
document.getElementById('target-editor-fps-value').textContent = fps;
document.getElementById('target-editor-keepalive-interval').value = cloneData.keepalive_interval ?? 1.0;
document.getElementById('target-editor-keepalive-interval-value').textContent = cloneData.keepalive_interval ?? 1.0;
document.getElementById('target-editor-title').innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
const cloneThresh = cloneData.min_brightness_threshold ?? 0;
document.getElementById('target-editor-brightness-threshold').value = cloneThresh;
document.getElementById('target-editor-brightness-threshold-value').textContent = cloneThresh;
document.getElementById('target-editor-adaptive-fps').checked = cloneData.adaptive_fps ?? false;
document.getElementById('target-editor-protocol').value = cloneData.protocol || 'ddp';
_populateCssDropdown(cloneData.color_strip_source_id || '');
_populateBrightnessVsDropdown(cloneData.brightness_value_source_id || '');
} else {
// Creating new target
document.getElementById('target-editor-id').value = '';
document.getElementById('target-editor-name').value = '';
document.getElementById('target-editor-fps').value = 30;
document.getElementById('target-editor-fps-value').textContent = '30';
document.getElementById('target-editor-keepalive-interval').value = 1.0;
document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0';
document.getElementById('target-editor-title').innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
document.getElementById('target-editor-brightness-threshold').value = 0;
document.getElementById('target-editor-brightness-threshold-value').textContent = '0';
document.getElementById('target-editor-adaptive-fps').checked = false;
document.getElementById('target-editor-protocol').value = 'ddp';
_populateCssDropdown('');
_populateBrightnessVsDropdown('');
}
// Entity palette selectors
_ensureTargetEntitySelects();
_ensureProtocolIconSelect();
if (_protocolIconSelect) _protocolIconSelect.setValue(document.getElementById('target-editor-protocol').value);
// Auto-name generation
_targetNameManuallyEdited = !!(targetId || cloneData);
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
window._targetAutoName = _autoGenerateTargetName;
deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateSpecificSettingsVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
document.getElementById('target-editor-css-source').onchange = () => { _autoGenerateTargetName(); };
document.getElementById('target-editor-brightness-vs').onchange = () => { _updateBrightnessThresholdVisibility(); };
if (!targetId && !cloneData) _autoGenerateTargetName();
// Show/hide conditional fields
_updateDeviceInfo();
_updateKeepaliveVisibility();
_updateSpecificSettingsVisibility();
_updateFpsRecommendation();
_updateBrightnessThresholdVisibility();
// Tags
if (_targetTagsInput) _targetTagsInput.destroy();
_targetTagsInput = new TagInput(document.getElementById('target-tags-container'), {
placeholder: window.t ? t('tags.placeholder') : 'Add tag...'
});
_targetTagsInput.setValue(_editorTags);
targetEditorModal.snapshot();
targetEditorModal.open();
document.getElementById('target-editor-error').style.display = 'none';
setTimeout(() => desktopFocus(document.getElementById('target-editor-name')), 100);
} catch (error) {
console.error('Failed to open target editor:', error);
showToast(t('target.error.editor_open_failed'), 'error');
}
}
export function isTargetEditorDirty() {
return targetEditorModal.isDirty();
}
export async function closeTargetEditorModal() {
await targetEditorModal.close();
}
export function forceCloseTargetEditorModal() {
if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; }
targetEditorModal.forceClose();
}
export async function saveTargetEditor() {
const targetId = document.getElementById('target-editor-id').value;
const name = document.getElementById('target-editor-name').value.trim();
const deviceId = document.getElementById('target-editor-device').value;
const standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value);
if (!name) {
targetEditorModal.showError(t('targets.error.name_required'));
return;
}
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
const colorStripSourceId = document.getElementById('target-editor-css-source').value;
const brightnessVsId = document.getElementById('target-editor-brightness-vs').value;
const minBrightnessThreshold = parseInt(document.getElementById('target-editor-brightness-threshold').value) || 0;
const adaptiveFps = document.getElementById('target-editor-adaptive-fps').checked;
const protocol = document.getElementById('target-editor-protocol').value;
const payload = {
name,
device_id: deviceId,
color_strip_source_id: colorStripSourceId,
brightness_value_source_id: brightnessVsId,
min_brightness_threshold: minBrightnessThreshold,
fps,
keepalive_interval: standbyInterval,
adaptive_fps: adaptiveFps,
protocol,
tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
};
try {
let response;
if (targetId) {
response = await fetchWithAuth(`/output-targets/${targetId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
} else {
payload.target_type = 'led';
response = await fetchWithAuth('/output-targets', {
method: 'POST',
body: JSON.stringify(payload),
});
}
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to save');
}
showToast(targetId ? t('targets.updated') : t('targets.created'), 'success');
outputTargetsCache.invalidate();
targetEditorModal.forceClose();
await loadTargetsTab();
} catch (error) {
if (error.isAuth) return;
console.error('Error saving target:', error);
targetEditorModal.showError(error.message);
}
}
// ===== TARGETS TAB (WLED devices + targets combined) =====
let _treeTriggered = false;
const _targetsTree = new TreeNav('targets-tree-nav', {
onSelect: (key) => {
_treeTriggered = true;
switchTargetSubTab(key);
_treeTriggered = false;
}
});
export function switchTargetSubTab(tabKey) {
document.querySelectorAll('.target-sub-tab-panel').forEach(panel =>
panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`)
);
localStorage.setItem('activeTargetSubTab', tabKey);
updateSubTabHash('targets', tabKey);
// Update tree active state (unless the tree triggered this switch)
if (!_treeTriggered) {
_targetsTree.setActive(tabKey);
}
}
const _targetSectionMap = {
'led-devices': [csDevices],
'led-targets': [csLedTargets],
'kc-targets': [csKCTargets],
'kc-patterns': [csPatternTemplates],
};
export function expandAllTargetSections() {
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led-devices';
CardSection.expandAll(_targetSectionMap[activeSubTab] || []);
}
export function collapseAllTargetSections() {
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led-devices';
CardSection.collapseAll(_targetSectionMap[activeSubTab] || []);
}
let _loadTargetsLock = false;
let _actionInFlight = false;
export async function loadTargetsTab() {
const container = document.getElementById('targets-panel-content');
if (!container) return;
// Skip if another loadTargetsTab or a button action is already running
if (_loadTargetsLock || _actionInFlight) return;
_loadTargetsLock = true;
if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true);
try {
// Fetch all entities via DataCache
const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([
devicesCache.fetch().catch(() => []),
outputTargetsCache.fetch().catch(() => []),
colorStripSourcesCache.fetch().catch(() => []),
patternTemplatesCache.fetch().catch(() => []),
streamsCache.fetch().catch(() => []),
valueSourcesCache.fetch().catch(() => []),
audioSourcesCache.fetch().catch(() => []),
syncClocksCache.fetch().catch(() => []),
]);
const colorStripSourceMap = {};
cssArr.forEach(s => { colorStripSourceMap[s.id] = s; });
let pictureSourceMap = {};
psArr.forEach(s => { pictureSourceMap[s.id] = s; });
let patternTemplateMap = {};
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
let valueSourceMap = {};
valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; });
let audioSourceMap = {};
asSrcArr.forEach(s => { audioSourceMap[s.id] = s; });
// Fetch all device states, target states, and target metrics in batch
const [batchDevStatesResp, batchTgtStatesResp, batchTgtMetricsResp] = await Promise.all([
fetchWithAuth('/devices/batch/states'),
fetchWithAuth('/output-targets/batch/states'),
fetchWithAuth('/output-targets/batch/metrics'),
]);
const allDeviceStates = batchDevStatesResp.ok ? (await batchDevStatesResp.json()).states : {};
const allTargetStates = batchTgtStatesResp.ok ? (await batchTgtStatesResp.json()).states : {};
const allTargetMetrics = batchTgtMetricsResp.ok ? (await batchTgtMetricsResp.json()).metrics : {};
const devicesWithState = devices.map(d => ({ ...d, state: allDeviceStates[d.id] || {} }));
// Enrich targets with state/metrics; fetch colors only for running KC targets
const targetsWithState = await Promise.all(
targets.map(async (target) => {
const state = allTargetStates[target.id] || {};
const metrics = allTargetMetrics[target.id] || {};
let latestColors = null;
if (target.target_type === 'key_colors' && state.processing) {
try {
const colorsResp = await fetch(`${API_BASE}/output-targets/${target.id}/colors`, { headers: getHeaders() });
if (colorsResp.ok) latestColors = await colorsResp.json();
} catch {}
}
return { ...target, state, metrics, latestColors };
})
);
// Build device map for target name resolution
const deviceMap = {};
devicesWithState.forEach(d => { deviceMap[d.id] = d; });
// Group by type
const ledDevices = devicesWithState;
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
// Update tab badge with running target count
const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length;
updateTabBadge('targets', runningCount);
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
// Build tree navigation structure
const treeGroups = [
{
key: 'led_group', icon: getTargetTypeIcon('led'), titleKey: 'targets.subtab.led',
children: [
{ key: 'led-devices', titleKey: 'targets.section.devices', icon: getDeviceTypeIcon('wled'), count: ledDevices.length },
{ key: 'led-targets', titleKey: 'targets.section.targets', icon: getTargetTypeIcon('led'), count: ledTargets.length },
]
},
{
key: 'kc_group', icon: getTargetTypeIcon('key_colors'), titleKey: 'targets.subtab.key_colors',
children: [
{ key: 'kc-targets', titleKey: 'targets.section.key_colors', icon: getTargetTypeIcon('key_colors'), count: kcTargets.length },
{ key: 'kc-patterns', titleKey: 'targets.section.pattern_templates', icon: ICON_TEMPLATE, count: patternTemplates.length },
]
}
];
// Determine which tree leaf is active — migrate old values
const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns'];
const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab
: activeSubTab === 'key_colors' ? 'kc-targets' : 'led-devices';
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
// Build items arrays for each section (apply saved drag order)
const deviceItems = csDevices.applySortOrder(ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) })));
const ledTargetItems = csLedTargets.applySortOrder(ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) })));
const kcTargetItems = csKCTargets.applySortOrder(kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) })));
const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
// Track which target cards were replaced/added (need chart re-init)
let changedTargetIds = null;
if (csDevices.isMounted()) {
// ── Incremental update: reconcile cards in-place ──
_targetsTree.updateCounts({
'led-devices': ledDevices.length,
'led-targets': ledTargets.length,
'kc-targets': kcTargets.length,
'kc-patterns': patternTemplates.length,
});
csDevices.reconcile(deviceItems);
const ledResult = csLedTargets.reconcile(ledTargetItems);
const kcResult = csKCTargets.reconcile(kcTargetItems);
csPatternTemplates.reconcile(patternItems);
changedTargetIds = new Set([...ledResult.added, ...ledResult.replaced, ...ledResult.removed,
...kcResult.added, ...kcResult.replaced, ...kcResult.removed]);
// Re-render cached LED preview frames onto new canvas elements after reconciliation
for (const id of ledResult.replaced) {
const frame = _ledPreviewLastFrame[id];
if (frame && ledPreviewWebSockets[id]) {
const canvas = document.getElementById(`led-preview-canvas-${id}`);
if (canvas) _renderLedStrip(canvas, frame);
}
}
} else {
// ── First render: build full HTML ──
const panels = [
{ key: 'led-devices', html: csDevices.render(deviceItems) },
{ key: 'led-targets', html: csLedTargets.render(ledTargetItems) },
{ key: 'kc-targets', html: csKCTargets.render(kcTargetItems) },
{ key: 'kc-patterns', html: csPatternTemplates.render(patternItems) },
].map(p => `<div class="target-sub-tab-panel stream-tab-panel${p.key === activeLeaf ? ' active' : ''}" id="target-sub-tab-${p.key}">${p.html}</div>`).join('');
container.innerHTML = panels;
CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates]);
// Render tree sidebar with expand/collapse buttons
_targetsTree.setExtraHtml(`<button class="btn-expand-collapse" onclick="expandAllTargetSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllTargetSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_targetsTree.update(treeGroups, activeLeaf);
_targetsTree.observeSections('targets-panel-content');
}
// Show/hide stop-all buttons based on running state
const ledRunning = ledTargets.some(t => t.state && t.state.processing);
const kcRunning = kcTargets.some(t => t.state && t.state.processing);
const ledStopBtn = container.querySelector('[data-stop-all="led"]');
const kcStopBtn = container.querySelector('[data-stop-all="kc"]');
if (ledStopBtn) { ledStopBtn.style.display = ledRunning ? '' : 'none'; if (!ledStopBtn.title) { ledStopBtn.title = t('targets.stop_all.button'); ledStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } }
if (kcStopBtn) { kcStopBtn.style.display = kcRunning ? '' : 'none'; if (!kcStopBtn.title) { kcStopBtn.title = t('targets.stop_all.button'); kcStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } }
// Patch volatile metrics in-place (avoids full card replacement on polls)
for (const tgt of ledTargets) {
if (tgt.state && tgt.state.processing) _patchTargetMetrics(tgt);
}
for (const tgt of kcTargets) {
if (tgt.state && tgt.state.processing) patchKCTargetMetrics(tgt);
}
// Attach event listeners and fetch brightness for device cards
devicesWithState.forEach(device => {
attachDeviceListeners(device.id);
if ((device.capabilities || []).includes('brightness_control')) {
if (device.id in _deviceBrightnessCache) {
const bri = _deviceBrightnessCache[device.id];
const slider = document.querySelector(`[data-device-brightness="${device.id}"]`);
if (slider) {
slider.value = bri;
slider.title = Math.round(bri / 255 * 100) + '%';
slider.disabled = false;
}
const wrap = document.querySelector(`[data-brightness-wrap="${device.id}"]`);
if (wrap) wrap.classList.remove('brightness-loading');
} else {
fetchDeviceBrightness(device.id);
}
}
// Enrich OpenRGB zone badges with per-zone LED counts
if (device.device_type === 'openrgb') {
enrichOpenrgbZoneBadges(device.id, device.url);
}
});
// 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 => {
if (target.state && target.state.processing) {
processingKCIds.add(target.id);
if (!kcWebSockets[target.id]) {
connectKCWebSocket(target.id);
}
}
});
Object.keys(kcWebSockets).forEach(id => {
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
});
// Auto-disconnect LED preview WebSockets for targets that stopped
const processingLedIds = new Set();
ledTargets.forEach(target => {
if (target.state && target.state.processing) processingLedIds.add(target.id);
});
Object.keys(ledPreviewWebSockets).forEach(id => {
if (!processingLedIds.has(id)) disconnectLedPreviewWS(id);
});
// FPS charts: only destroy charts for replaced/removed cards (or all on first render)
if (changedTargetIds) {
// Incremental: destroy only charts whose cards were replaced or removed
for (const id of changedTargetIds) {
if (_targetFpsCharts[id]) {
_targetFpsCharts[id].destroy();
delete _targetFpsCharts[id];
}
}
} else {
// First render: destroy all old charts
for (const id of Object.keys(_targetFpsCharts)) {
_targetFpsCharts[id].destroy();
delete _targetFpsCharts[id];
}
}
// Push FPS samples and create/update charts for running targets
const allTargets = [...ledTargets, ...kcTargets];
const runningIds = new Set();
allTargets.forEach(target => {
if (target.state && target.state.processing) {
runningIds.add(target.id);
if (target.state.fps_actual != null) {
_pushTargetFps(target.id, target.state.fps_actual, target.state.fps_current ?? 0);
}
// Create chart if it doesn't exist (new or replaced card)
if (!_targetFpsCharts[target.id]) {
const actualH = _targetFpsHistory[target.id] || [];
const currentH = _targetFpsCurrentHistory[target.id] || [];
const fpsTarget = target.state.fps_target || 30;
const device = devices.find(d => d.id === target.device_id);
const maxHwFps = device ? _computeMaxFps(device.baud_rate, device.led_count, device.device_type) : null;
const chart = _createTargetFpsChart(`target-fps-${target.id}`, actualH, currentH, fpsTarget, maxHwFps);
if (chart) _targetFpsCharts[target.id] = chart;
} else {
// Chart survived reconcile — just update data
_updateTargetFpsChart(target.id, target.state.fps_target || 30);
}
}
});
// Clean up history and charts for targets no longer running
Object.keys(_targetFpsHistory).forEach(id => {
if (!runningIds.has(id)) { delete _targetFpsHistory[id]; delete _targetFpsCurrentHistory[id]; }
});
Object.keys(_targetFpsCharts).forEach(id => {
if (!runningIds.has(id)) {
_targetFpsCharts[id].destroy();
delete _targetFpsCharts[id];
}
});
} catch (error) {
if (error.isAuth) return;
console.error('Failed to load targets tab:', error);
container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`;
} finally {
_loadTargetsLock = false;
setTabRefreshing('targets-panel-content', false);
}
}
function _cssSourceName(cssId, colorStripSourceMap) {
if (!cssId) return t('targets.no_css');
const css = colorStripSourceMap[cssId];
return css ? escapeHtml(css.name) : escapeHtml(cssId);
}
// ─── In-place metric patching (avoids full card replacement on polls) ───
function _buildLedTimingHTML(state) {
const isAudio = state.timing_audio_read_ms != null;
return `
<div class="timing-header">
<div class="metric-label">${t('device.metrics.timing')}</div>
<div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
</div>
<div class="timing-bar">
${isAudio ? `
<span class="timing-seg timing-audio-read" style="flex:${state.timing_audio_read_ms}" title="read ${state.timing_audio_read_ms}ms"></span>
<span class="timing-seg timing-audio-fft" style="flex:${state.timing_audio_fft_ms}" title="fft ${state.timing_audio_fft_ms}ms"></span>
<span class="timing-seg timing-audio-render" style="flex:${state.timing_audio_render_ms || 0.1}" title="render ${state.timing_audio_render_ms}ms"></span>
` : `
${state.timing_extract_ms != null ? `<span class="timing-seg timing-extract" style="flex:${state.timing_extract_ms}" title="extract ${state.timing_extract_ms}ms"></span>` : ''}
${state.timing_map_leds_ms != null ? `<span class="timing-seg timing-map" style="flex:${state.timing_map_leds_ms}" title="map ${state.timing_map_leds_ms}ms"></span>` : ''}
${state.timing_smooth_ms != null ? `<span class="timing-seg timing-smooth" style="flex:${state.timing_smooth_ms || 0.1}" title="smooth ${state.timing_smooth_ms}ms"></span>` : ''}
`}
<span class="timing-seg timing-send" style="flex:${state.timing_send_ms}" title="send ${state.timing_send_ms}ms"></span>
</div>
<div class="timing-legend">
${isAudio ? `
<span class="timing-legend-item"><span class="timing-dot timing-audio-read"></span>read ${state.timing_audio_read_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-audio-fft"></span>fft ${state.timing_audio_fft_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-audio-render"></span>render ${state.timing_audio_render_ms}ms</span>
` : `
${state.timing_extract_ms != null ? `<span class="timing-legend-item"><span class="timing-dot timing-extract"></span>extract ${state.timing_extract_ms}ms</span>` : ''}
${state.timing_map_leds_ms != null ? `<span class="timing-legend-item"><span class="timing-dot timing-map"></span>map ${state.timing_map_leds_ms}ms</span>` : ''}
${state.timing_smooth_ms != null ? `<span class="timing-legend-item"><span class="timing-dot timing-smooth"></span>smooth ${state.timing_smooth_ms}ms</span>` : ''}
`}
<span class="timing-legend-item"><span class="timing-dot timing-send"></span>send ${state.timing_send_ms}ms</span>
</div>`;
}
function _patchTargetMetrics(target) {
const container = document.getElementById('targets-panel-content');
if (!container) return;
const card = container.querySelector(`[data-target-id="${target.id}"]`);
if (!card) return;
const state = target.state || {};
const metrics = target.metrics || {};
const fps = card.querySelector('[data-tm="fps"]');
if (fps) {
const effFps = state.fps_effective;
const tgtFps = state.fps_target || 0;
const fpsLabel = (effFps != null && effFps < tgtFps)
? `${state.fps_current ?? 0}<span class="target-fps-target">/${effFps}${tgtFps}</span>`
: `${state.fps_current ?? 0}<span class="target-fps-target">/${tgtFps}</span>`;
const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : '';
fps.innerHTML = `<span class="${unreachableClass}">${fpsLabel}</span>`
+ `<span class="target-fps-avg">avg ${state.fps_actual?.toFixed(1) || '0.0'}</span>`;
}
// Update health dot to reflect streaming reachability when processing
const healthDot = card.querySelector('.health-dot');
if (healthDot && state.processing) {
const reachable = state.device_streaming_reachable;
if (reachable === false) {
healthDot.className = 'health-dot health-offline';
healthDot.title = t('device.health.streaming_unreachable') || 'Unreachable during streaming';
} else if (reachable === true) {
healthDot.className = 'health-dot health-online';
healthDot.title = t('device.health.online');
}
}
const timing = card.querySelector('[data-tm="timing"]');
if (timing && state.timing_total_ms != null) timing.innerHTML = _buildLedTimingHTML(state);
const frames = card.querySelector('[data-tm="frames"]');
if (frames) { frames.textContent = formatCompact(metrics.frames_processed || 0); frames.title = String(metrics.frames_processed || 0); }
const keepalive = card.querySelector('[data-tm="keepalive"]');
if (keepalive) { keepalive.textContent = formatCompact(state.frames_keepalive ?? 0); keepalive.title = String(state.frames_keepalive ?? 0); }
const errors = card.querySelector('[data-tm="errors"]');
if (errors) { errors.textContent = formatCompact(metrics.errors_count || 0); errors.title = String(metrics.errors_count || 0); }
// Error indicator near target name
const errorIndicator = card.querySelector('.target-error-indicator');
if (errorIndicator) {
const hasErrors = (metrics.errors_count || 0) > 0;
errorIndicator.classList.toggle('visible', hasErrors);
}
const uptime = card.querySelector('[data-tm="uptime"]');
if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds);
}
export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSourceMap) {
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: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(target.name)}">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${escapeHtml(target.name)}
<span class="target-error-indicator" title="${t('device.metrics.errors')}">${ICON_WARNING}</span>
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led-devices','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${target.fps || 30}</span>
<span class="stream-card-prop" title="${t('targets.protocol')}">${_protocolBadge(device, target)}</span>
<span class="stream-card-prop${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
${target.min_brightness_threshold > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} &lt;${target.min_brightness_threshold} → off</span>` : ''}
</div>
${renderTagChips(target.tags)}
<div class="card-content">
${isProcessing ? `
<div class="metrics-grid">
<div class="target-fps-row">
<div class="target-fps-sparkline">
<canvas id="target-fps-${target.id}" data-fps-target="${state.fps_target || 30}"></canvas>
</div>
<div class="target-fps-label">
<span class="metric-value" data-tm="fps">---</span>
</div>
</div>
</div>
<div class="target-metrics-collapse">
<button type="button" class="target-metrics-toggle" onclick="this.parentElement.classList.toggle('open')">${t('targets.metrics.pipeline')}</button>
<div class="target-metrics-animate">
<div class="metrics-grid target-metrics-expanded">
<div class="timing-breakdown" data-tm="timing" style="grid-column:1/-1"></div>
<div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value" data-tm="frames">---</div>
</div>
${state.needs_keepalive !== false ? `
<div class="metric">
<div class="metric-label">${t('device.metrics.keepalive')}</div>
<div class="metric-value" data-tm="keepalive">---</div>
</div>
` : ''}
<div class="metric">
<div class="metric-label">${t('device.metrics.errors')}</div>
<div class="metric-value" data-tm="errors">---</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.uptime')}</div>
<div class="metric-value" data-tm="uptime">---</div>
</div>
</div>
</div>
</div>
` : ''}
</div>
${_buildLedPreviewHtml(target.id, device, bvsId, css, colorStripSourceMap)}`,
actions: `
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
${ICON_STOP}
</button>
` : `
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('device.button.start')}">
${ICON_START}
</button>
`}
${isProcessing ? `
<button class="btn btn-icon ${ledPreviewWebSockets[target.id] ? 'btn-warning' : 'btn-secondary'}" onclick="toggleLedPreview('${target.id}')" title="LED Preview">
${ICON_LED_PREVIEW}
</button>
` : ''}
<button class="btn btn-icon btn-secondary" onclick="cloneTarget('${target.id}')" title="${t('common.clone')}">
${ICON_CLONE}
</button>
<button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
${ICON_EDIT}
</button>
`,
});
}
async function _targetAction(action) {
_actionInFlight = true;
try {
await action();
} finally {
_actionInFlight = false;
_loadTargetsLock = false; // ensure next poll can run
loadTargetsTab();
}
}
export async function startTargetProcessing(targetId) {
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) {
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) {
try {
const [allTargets, statesResp] = await Promise.all([
outputTargetsCache.fetch().catch(() => []),
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) {
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) {
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) {
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) {
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, device, bvsId, cssSource, colorStripSourceMap) {
const visible = ledPreviewWebSockets[targetId] ? '' : '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 =>
`<div class="led-preview-zone">` +
`<canvas class="led-preview-canvas led-preview-zone-canvas" data-zone-name="${escapeHtml(z)}"></canvas>` +
`<span class="led-preview-zone-label">${escapeHtml(z)}</span>` +
`</div>`
).join('');
return `<div id="led-preview-panel-${targetId}" class="led-preview-panel led-preview-zones" data-zone-mode="separate" data-zone-base-url="${escapeHtml(baseUrl)}" style="display:${visible}">` +
zoneCanvases +
`<span id="led-preview-brightness-${targetId}" class="led-preview-brightness" style="display:none"${bvsAttr}></span>` +
`</div>`;
}
}
// 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 `<div class="led-preview-layer">` +
`<canvas class="led-preview-canvas led-preview-layer-canvas" data-layer-idx="${i}"></canvas>` +
`<span class="led-preview-layer-label">${escapeHtml(layerName)}</span>` +
`</div>`;
}).join('');
return `<div id="led-preview-panel-${targetId}" class="led-preview-panel led-preview-layers" data-composite="1" style="display:${visible}">` +
`<div class="led-preview-layer led-preview-layer-composite">` +
`<canvas class="led-preview-canvas led-preview-layer-canvas" data-layer-idx="composite"></canvas>` +
`<span class="led-preview-layer-label">${escapeHtml(cssSource.name || 'Composite')}</span>` +
`</div>` +
layerCanvases +
`<span id="led-preview-brightness-${targetId}" class="led-preview-brightness" style="display:none"${bvsAttr}></span>` +
`</div>`;
}
// Default: single canvas
return `<div id="led-preview-panel-${targetId}" class="led-preview-panel" style="display:${visible}">` +
`<canvas id="led-preview-canvas-${targetId}" class="led-preview-canvas"></canvas>` +
`<span id="led-preview-brightness-${targetId}" class="led-preview-brightness" style="display:none"${bvsAttr}></span>` +
`</div>`;
}
/**
* Resample an RGB byte array from srcCount pixels to dstCount pixels
* using linear interpolation (matches backend np.interp behavior).
*/
function _resampleStrip(srcBytes, srcCount, dstCount) {
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, rgbBytes) {
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;
}
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, rgbBytes) {
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) {
disconnectLedPreviewWS(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}`);
// Composite wire format: [brightness] [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layers...] [composite...]
if (raw.length > 4 && raw[1] === 0xFE && panel && panel.dataset.composite === '1') {
const layerCount = raw[2];
const ledCount = (raw[3] << 8) | raw[4];
const rgbSize = ledCount * 3;
let offset = 5;
// Render per-layer canvases (individual layers)
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);
}
// Final composite result
const compositeRgb = raw.subarray(offset, offset + rgbSize);
_ledPreviewLastFrame[targetId] = compositeRgb;
const compositeCanvas = panel.querySelector('[data-layer-idx="composite"]');
if (compositeCanvas) _renderLedStrip(compositeCanvas, 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 disconnectLedPreviewWS(targetId) {
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';
}
export function disconnectAllLedPreviewWS() {
Object.keys(ledPreviewWebSockets).forEach(id => disconnectLedPreviewWS(id));
}
export function toggleLedPreview(targetId) {
const panel = document.getElementById(`led-preview-panel-${targetId}`);
if (!panel) return;
if (ledPreviewWebSockets[targetId]) {
disconnectLedPreviewWS(targetId);
} else {
panel.style.display = '';
connectLedPreviewWS(targetId);
}
}