Introduce ColorStripSource as first-class entity

Extracts color processing and calibration out of WledPictureTarget into a
new PictureColorStripSource entity, enabling multiple LED targets to share
one capture/processing pipeline.

New entities & processing:
- storage/color_strip_source.py: ColorStripSource + PictureColorStripSource models
- storage/color_strip_store.py: JSON-backed CRUD store (prefix css_)
- core/processing/color_strip_stream.py: ColorStripStream ABC + PictureColorStripStream (runs border-extract → map → smooth → brightness/sat/gamma in background thread)
- core/processing/color_strip_stream_manager.py: ref-counted shared stream manager

Modified storage/processing:
- WledPictureTarget simplified to device_id + color_strip_source_id + standby_interval + state_check_interval
- Device model: calibration field removed
- WledTargetProcessor: acquires ColorStripStream from manager instead of running its own pipeline
- ProcessorManager: wires ColorStripStreamManager into TargetContext

API layer:
- New routes: GET/POST/PUT/DELETE /api/v1/color-strip-sources, PUT calibration/test
- Removed calibration endpoints from /devices
- Updated /picture-targets CRUD for new target structure

Frontend:
- New color-strips.js module with CSS editor modal and card rendering
- Calibration modal extended with CSS mode (css-id hidden field + device picker)
- targets.js: Color Strip Sources section added to LED tab; target editor/card updated
- app.js: imports and window globals for CSS + showCSSCalibration
- en.json / ru.json: color_strip.* and targets.section.color_strips keys added

Data migration runs at startup: existing WledPictureTargets are converted to
reference a new PictureColorStripSource created from their old settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 15:49:47 +03:00
parent c4e0257389
commit 7de3546b14
33 changed files with 2325 additions and 814 deletions

View File

@@ -87,11 +87,17 @@ import {
startTargetOverlay, stopTargetOverlay, deleteTarget,
} from './features/targets.js';
// Layer 5: color-strip sources
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
} from './features/color-strips.js';
// Layer 5: calibration
import {
showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration,
updateOffsetSkipLock, updateCalibrationPreview,
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
showCSSCalibration,
} from './features/calibration.js';
// Layer 6: tabs
@@ -262,6 +268,13 @@ Object.assign(window, {
stopTargetOverlay,
deleteTarget,
// color-strip sources
showCSSEditor,
closeCSSEditorModal,
forceCSSEditorClose,
saveCSSEditor,
deleteColorStrip,
// calibration
showCalibration,
closeCalibrationModal,
@@ -273,6 +286,7 @@ Object.assign(window, {
toggleEdgeInputs,
toggleDirection,
toggleTestEdge,
showCSSCalibration,
// tabs
switchTab,

View File

@@ -35,8 +35,15 @@ class CalibrationModal extends Modal {
onForceClose() {
closeTutorial();
const deviceId = this.$('calibration-device-id').value;
if (deviceId) clearTestMode(deviceId);
if (_isCSS()) {
_clearCSSTestMode();
document.getElementById('calibration-css-id').value = '';
const testGroup = document.getElementById('calibration-css-test-group');
if (testGroup) testGroup.style.display = 'none';
} else {
const deviceId = this.$('calibration-device-id').value;
if (deviceId) clearTestMode(deviceId);
}
if (window._calibrationResizeObserver) window._calibrationResizeObserver.disconnect();
const error = this.$('calibration-error');
if (error) error.style.display = 'none';
@@ -48,6 +55,33 @@ const calibModal = new CalibrationModal();
let _dragRaf = null;
let _previewRaf = null;
/* ── Helpers ──────────────────────────────────────────────────── */
function _isCSS() {
return !!(document.getElementById('calibration-css-id')?.value);
}
function _cssStateKey() {
return `css_${document.getElementById('calibration-css-id').value}`;
}
async function _clearCSSTestMode() {
const cssId = document.getElementById('calibration-css-id')?.value;
const stateKey = _cssStateKey();
if (!cssId || !calibrationTestState[stateKey] || calibrationTestState[stateKey].size === 0) return;
calibrationTestState[stateKey] = new Set();
const testDeviceId = document.getElementById('calibration-test-device')?.value;
if (!testDeviceId) return;
try {
await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
method: 'PUT',
body: JSON.stringify({ device_id: testDeviceId, edges: {} }),
});
} catch (err) {
console.error('Failed to clear CSS test mode:', err);
}
}
/* ── Public API (exported names unchanged) ────────────────────── */
export async function showCalibration(deviceId) {
@@ -148,6 +182,92 @@ export async function closeCalibrationModal() {
calibModal.close();
}
/* ── CSS Calibration support ──────────────────────────────────── */
export async function showCSSCalibration(cssId) {
try {
const [cssResp, devicesResp] = await Promise.all([
fetchWithAuth(`/color-strip-sources/${cssId}`),
fetchWithAuth('/devices'),
]);
if (!cssResp.ok) { showToast('Failed to load color strip source', 'error'); return; }
const source = await cssResp.json();
const calibration = source.calibration || {};
// Set CSS mode — clear device-id, set css-id
document.getElementById('calibration-device-id').value = '';
document.getElementById('calibration-css-id').value = cssId;
// Populate device picker for edge test
const devices = devicesResp.ok ? ((await devicesResp.json()).devices || []) : [];
const testDeviceSelect = document.getElementById('calibration-test-device');
testDeviceSelect.innerHTML = '';
devices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.id;
opt.textContent = d.name;
testDeviceSelect.appendChild(opt);
});
const testGroup = document.getElementById('calibration-css-test-group');
testGroup.style.display = devices.length ? '' : 'none';
// Populate calibration fields
const preview = document.querySelector('.calibration-preview');
preview.style.aspectRatio = '';
document.getElementById('cal-device-led-count-inline').textContent = '—';
document.getElementById('cal-start-position').value = calibration.start_position || 'bottom_left';
document.getElementById('cal-layout').value = calibration.layout || 'clockwise';
document.getElementById('cal-offset').value = calibration.offset || 0;
document.getElementById('cal-top-leds').value = calibration.leds_top || 0;
document.getElementById('cal-right-leds').value = calibration.leds_right || 0;
document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0;
document.getElementById('cal-left-leds').value = calibration.leds_left || 0;
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0;
document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0;
updateOffsetSkipLock();
document.getElementById('cal-border-width').value = calibration.border_width || 10;
window.edgeSpans = {
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
right: { start: calibration.span_right_start ?? 0, end: calibration.span_right_end ?? 1 },
bottom: { start: calibration.span_bottom_start ?? 0, end: calibration.span_bottom_end ?? 1 },
left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 },
};
calibrationTestState[_cssStateKey()] = new Set();
updateCalibrationPreview();
calibModal.snapshot();
calibModal.open();
initSpanDrag();
requestAnimationFrame(() => renderCalibrationCanvas());
if (!window._calibrationResizeObserver) {
window._calibrationResizeObserver = new ResizeObserver(() => {
if (window._calibrationResizeRaf) return;
window._calibrationResizeRaf = requestAnimationFrame(() => {
window._calibrationResizeRaf = null;
updateSpanBars();
renderCalibrationCanvas();
});
});
}
window._calibrationResizeObserver.observe(preview);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to load CSS calibration:', error);
showToast('Failed to load calibration', 'error');
}
}
export function updateOffsetSkipLock() {
const offsetEl = document.getElementById('cal-offset');
const skipStartEl = document.getElementById('cal-skip-start');
@@ -165,8 +285,9 @@ export function updateCalibrationPreview() {
parseInt(document.getElementById('cal-bottom-leds').value || 0) +
parseInt(document.getElementById('cal-left-leds').value || 0);
const totalEl = document.querySelector('.preview-screen-total');
const deviceCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0);
const mismatch = total !== deviceCount;
const inCSS = _isCSS();
const deviceCount = inCSS ? null : parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0);
const mismatch = !inCSS && total !== deviceCount;
document.getElementById('cal-total-leds-inline').textContent = (mismatch ? '\u26A0 ' : '') + total;
if (totalEl) totalEl.classList.toggle('mismatch', mismatch);
@@ -186,7 +307,8 @@ export function updateCalibrationPreview() {
if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
const deviceId = document.getElementById('calibration-device-id').value;
const activeEdges = calibrationTestState[deviceId] || new Set();
const stateKey = _isCSS() ? _cssStateKey() : deviceId;
const activeEdges = calibrationTestState[stateKey] || new Set();
['top', 'right', 'bottom', 'left'].forEach(edge => {
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`);
@@ -612,9 +734,42 @@ export async function toggleTestEdge(edge) {
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
if (edgeLeds === 0) return;
const deviceId = document.getElementById('calibration-device-id').value;
const error = document.getElementById('calibration-error');
if (_isCSS()) {
const cssId = document.getElementById('calibration-css-id').value;
const testDeviceId = document.getElementById('calibration-test-device')?.value;
if (!testDeviceId) return;
const stateKey = _cssStateKey();
if (!calibrationTestState[stateKey]) calibrationTestState[stateKey] = new Set();
if (calibrationTestState[stateKey].has(edge)) calibrationTestState[stateKey].delete(edge);
else calibrationTestState[stateKey].add(edge);
const edges = {};
calibrationTestState[stateKey].forEach(e => { edges[e] = EDGE_TEST_COLORS[e]; });
updateCalibrationPreview();
try {
const response = await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
method: 'PUT',
body: JSON.stringify({ device_id: testDeviceId, edges }),
});
if (!response.ok) {
const errorData = await response.json();
error.textContent = `Test failed: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
if (err.isAuth) return;
console.error('Failed to toggle CSS test edge:', err);
error.textContent = 'Failed to toggle test edge';
error.style.display = 'block';
}
return;
}
const deviceId = document.getElementById('calibration-device-id').value;
if (!calibrationTestState[deviceId]) calibrationTestState[deviceId] = new Set();
if (calibrationTestState[deviceId].has(edge)) calibrationTestState[deviceId].delete(edge);
@@ -658,11 +813,16 @@ async function clearTestMode(deviceId) {
}
export async function saveCalibration() {
const cssMode = _isCSS();
const deviceId = document.getElementById('calibration-device-id').value;
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent);
const cssId = document.getElementById('calibration-css-id').value;
const error = document.getElementById('calibration-error');
await clearTestMode(deviceId);
if (cssMode) {
await _clearCSSTestMode();
} else {
await clearTestMode(deviceId);
}
updateCalibrationPreview();
const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
@@ -671,10 +831,13 @@ export async function saveCalibration() {
const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0);
const total = topLeds + rightLeds + bottomLeds + leftLeds;
if (total !== deviceLedCount) {
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
error.style.display = 'block';
return;
if (!cssMode) {
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count-inline').textContent);
if (total !== deviceLedCount) {
error.textContent = `Total LEDs (${total}) must equal device LED count (${deviceLedCount})`;
error.style.display = 'block';
return;
}
}
const startPosition = document.getElementById('cal-start-position').value;
@@ -695,14 +858,26 @@ export async function saveCalibration() {
};
try {
const response = await fetchWithAuth(`/devices/${deviceId}/calibration`, {
method: 'PUT',
body: JSON.stringify(calibration)
});
let response;
if (cssMode) {
response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
method: 'PUT',
body: JSON.stringify({ calibration }),
});
} else {
response = await fetchWithAuth(`/devices/${deviceId}/calibration`, {
method: 'PUT',
body: JSON.stringify(calibration),
});
}
if (response.ok) {
showToast('Calibration saved', 'success');
calibModal.forceClose();
window.loadDevices();
if (cssMode) {
if (window.loadTargetsTab) window.loadTargetsTab();
} else {
window.loadDevices();
}
} else {
const errorData = await response.json();
error.textContent = `Failed to save: ${errorData.detail}`;

View File

@@ -0,0 +1,218 @@
/**
* Color Strip Sources — CRUD, card rendering, calibration bridge.
*/
import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
class CSSEditorModal extends Modal {
constructor() {
super('css-editor-modal');
}
snapshotValues() {
return {
name: document.getElementById('css-editor-name').value,
picture_source: document.getElementById('css-editor-picture-source').value,
fps: document.getElementById('css-editor-fps').value,
interpolation: document.getElementById('css-editor-interpolation').value,
smoothing: document.getElementById('css-editor-smoothing').value,
brightness: document.getElementById('css-editor-brightness').value,
saturation: document.getElementById('css-editor-saturation').value,
gamma: document.getElementById('css-editor-gamma').value,
};
}
}
const cssEditorModal = new CSSEditorModal();
/* ── Card ─────────────────────────────────────────────────────── */
export function createColorStripCard(source, pictureSourceMap) {
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
? pictureSourceMap[source.picture_source_id].name
: source.picture_source_id || '—';
const cal = source.calibration || {};
const ledCount = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0);
return `
<div class="card" data-css-id="${source.id}">
<button class="card-remove-btn" onclick="deleteColorStrip('${source.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="card-header">
<div class="card-title">
🎞️ ${escapeHtml(source.name)}
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('color_strip.fps')}">⚡ ${source.fps || 30} fps</span>
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${ledCount}</span>` : ''}
<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">📺 ${escapeHtml(srcName)}</span>
</div>
<div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">✏️</button>
<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>
</div>
</div>
`;
}
/* ── Editor open/close ────────────────────────────────────────── */
export async function showCSSEditor(cssId = null) {
try {
const sourcesResp = await fetchWithAuth('/picture-sources');
const sources = sourcesResp.ok ? ((await sourcesResp.json()).streams || []) : [];
const sourceSelect = document.getElementById('css-editor-picture-source');
sourceSelect.innerHTML = '';
sources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨';
opt.textContent = `${typeIcon} ${s.name}`;
sourceSelect.appendChild(opt);
});
if (cssId) {
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`);
if (!resp.ok) throw new Error('Failed to load color strip source');
const css = await resp.json();
document.getElementById('css-editor-id').value = css.id;
document.getElementById('css-editor-name').value = css.name;
sourceSelect.value = css.picture_source_id || '';
const fps = css.fps ?? 30;
document.getElementById('css-editor-fps').value = fps;
document.getElementById('css-editor-fps-value').textContent = fps;
document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average';
const smoothing = css.smoothing ?? 0.3;
document.getElementById('css-editor-smoothing').value = smoothing;
document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2);
const brightness = css.brightness ?? 1.0;
document.getElementById('css-editor-brightness').value = brightness;
document.getElementById('css-editor-brightness-value').textContent = parseFloat(brightness).toFixed(2);
const saturation = css.saturation ?? 1.0;
document.getElementById('css-editor-saturation').value = saturation;
document.getElementById('css-editor-saturation-value').textContent = parseFloat(saturation).toFixed(2);
const gamma = css.gamma ?? 1.0;
document.getElementById('css-editor-gamma').value = gamma;
document.getElementById('css-editor-gamma-value').textContent = parseFloat(gamma).toFixed(2);
document.getElementById('css-editor-title').textContent = t('color_strip.edit');
} else {
document.getElementById('css-editor-id').value = '';
document.getElementById('css-editor-name').value = '';
document.getElementById('css-editor-fps').value = 30;
document.getElementById('css-editor-fps-value').textContent = '30';
document.getElementById('css-editor-interpolation').value = 'average';
document.getElementById('css-editor-smoothing').value = 0.3;
document.getElementById('css-editor-smoothing-value').textContent = '0.30';
document.getElementById('css-editor-brightness').value = 1.0;
document.getElementById('css-editor-brightness-value').textContent = '1.00';
document.getElementById('css-editor-saturation').value = 1.0;
document.getElementById('css-editor-saturation-value').textContent = '1.00';
document.getElementById('css-editor-gamma').value = 1.0;
document.getElementById('css-editor-gamma-value').textContent = '1.00';
document.getElementById('css-editor-title').textContent = t('color_strip.add');
}
document.getElementById('css-editor-error').style.display = 'none';
cssEditorModal.snapshot();
cssEditorModal.open();
setTimeout(() => document.getElementById('css-editor-name').focus(), 100);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to open CSS editor:', error);
showToast('Failed to open color strip editor', 'error');
}
}
export function closeCSSEditorModal() { cssEditorModal.close(); }
export function forceCSSEditorClose() { cssEditorModal.forceClose(); }
export function isCSSEditorDirty() { return cssEditorModal.isDirty(); }
/* ── Save ─────────────────────────────────────────────────────── */
export async function saveCSSEditor() {
const cssId = document.getElementById('css-editor-id').value;
const name = document.getElementById('css-editor-name').value.trim();
if (!name) {
cssEditorModal.showError(t('color_strip.error.name_required'));
return;
}
const payload = {
name,
picture_source_id: document.getElementById('css-editor-picture-source').value,
fps: parseInt(document.getElementById('css-editor-fps').value) || 30,
interpolation_mode: document.getElementById('css-editor-interpolation').value,
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
brightness: parseFloat(document.getElementById('css-editor-brightness').value),
saturation: parseFloat(document.getElementById('css-editor-saturation').value),
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
};
try {
let response;
if (cssId) {
response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
} else {
payload.source_type = 'picture';
response = await fetchWithAuth('/color-strip-sources', {
method: 'POST',
body: JSON.stringify(payload),
});
}
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to save');
}
showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success');
cssEditorModal.forceClose();
if (window.loadTargetsTab) await window.loadTargetsTab();
} catch (error) {
if (error.isAuth) return;
console.error('Error saving CSS:', error);
cssEditorModal.showError(error.message);
}
}
/* ── Delete ───────────────────────────────────────────────────── */
export async function deleteColorStrip(cssId) {
const confirmed = await showConfirm(t('color_strip.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
method: 'DELETE',
});
if (response.ok) {
showToast(t('color_strip.deleted'), 'success');
if (window.loadTargetsTab) await window.loadTargetsTab();
} else {
const err = await response.json();
const msg = err.detail || 'Failed to delete';
const isReferenced = response.status === 409;
showToast(isReferenced ? t('color_strip.delete.referenced') : `Failed: ${msg}`, 'error');
}
} catch (error) {
if (error.isAuth) return;
showToast('Failed to delete color strip source', 'error');
}
}

View File

@@ -14,6 +14,7 @@ import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness } from './devices.js';
import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
import { createColorStripCard } from './color-strips.js';
// createPatternTemplateCard is imported via window.* to avoid circular deps
// (pattern-templates.js calls window.loadTargetsTab)
@@ -30,10 +31,7 @@ class TargetEditorModal extends Modal {
return {
name: document.getElementById('target-editor-name').value,
device: document.getElementById('target-editor-device').value,
source: document.getElementById('target-editor-source').value,
fps: document.getElementById('target-editor-fps').value,
interpolation: document.getElementById('target-editor-interpolation').value,
smoothing: document.getElementById('target-editor-smoothing').value,
css: document.getElementById('target-editor-css').value,
standby_interval: document.getElementById('target-editor-standby-interval').value,
};
}
@@ -47,11 +45,11 @@ function _autoGenerateTargetName() {
if (_targetNameManuallyEdited) return;
if (document.getElementById('target-editor-id').value) return;
const deviceSelect = document.getElementById('target-editor-device');
const sourceSelect = document.getElementById('target-editor-source');
const cssSelect = document.getElementById('target-editor-css');
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
if (!deviceName || !sourceName) return;
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${sourceName}`;
const cssName = cssSelect.selectedOptions[0]?.dataset?.name || '';
if (!deviceName || !cssName) return;
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
}
function _updateStandbyVisibility() {
@@ -64,14 +62,14 @@ function _updateStandbyVisibility() {
export async function showTargetEditor(targetId = null) {
try {
// Load devices and sources for dropdowns
const [devicesResp, sourcesResp] = await Promise.all([
// Load devices and CSS sources for dropdowns
const [devicesResp, cssResp] = await Promise.all([
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
fetchWithAuth('/picture-sources'),
fetchWithAuth('/color-strip-sources'),
]);
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
const sources = sourcesResp.ok ? (await sourcesResp.json()).streams || [] : [];
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
set_targetEditorDevices(devices);
// Populate device select
@@ -87,16 +85,15 @@ export async function showTargetEditor(targetId = null) {
deviceSelect.appendChild(opt);
});
// Populate source select
const sourceSelect = document.getElementById('target-editor-source');
sourceSelect.innerHTML = '';
sources.forEach(s => {
// Populate color strip source select
const cssSelect = document.getElementById('target-editor-css');
cssSelect.innerHTML = '';
cssSources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
const typeIcon = s.stream_type === 'raw' ? '\uD83D\uDDA5\uFE0F' : s.stream_type === 'static_image' ? '\uD83D\uDDBC\uFE0F' : '\uD83C\uDFA8';
opt.textContent = `${typeIcon} ${s.name}`;
sourceSelect.appendChild(opt);
opt.textContent = `🎞️ ${s.name}`;
cssSelect.appendChild(opt);
});
if (targetId) {
@@ -108,24 +105,14 @@ export async function showTargetEditor(targetId = null) {
document.getElementById('target-editor-id').value = target.id;
document.getElementById('target-editor-name').value = target.name;
deviceSelect.value = target.device_id || '';
sourceSelect.value = target.picture_source_id || '';
document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30;
document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30;
document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average';
document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3;
document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3;
document.getElementById('target-editor-standby-interval').value = target.settings?.standby_interval ?? 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = target.settings?.standby_interval ?? 1.0;
cssSelect.value = target.color_strip_source_id || '';
document.getElementById('target-editor-standby-interval').value = target.standby_interval ?? 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = target.standby_interval ?? 1.0;
document.getElementById('target-editor-title').textContent = t('targets.edit');
} else {
// Creating new target — first option is selected by default
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-interpolation').value = 'average';
document.getElementById('target-editor-smoothing').value = 0.3;
document.getElementById('target-editor-smoothing-value').textContent = '0.3';
document.getElementById('target-editor-standby-interval').value = 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = '1.0';
document.getElementById('target-editor-title').textContent = t('targets.add');
@@ -135,7 +122,7 @@ export async function showTargetEditor(targetId = null) {
_targetNameManuallyEdited = !!targetId;
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
deviceSelect.onchange = () => { _updateStandbyVisibility(); _autoGenerateTargetName(); };
sourceSelect.onchange = () => _autoGenerateTargetName();
cssSelect.onchange = () => _autoGenerateTargetName();
if (!targetId) _autoGenerateTargetName();
// Show/hide standby interval based on selected device capabilities
@@ -168,10 +155,7 @@ 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 sourceId = document.getElementById('target-editor-source').value;
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
const interpolation = document.getElementById('target-editor-interpolation').value;
const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value);
const cssId = document.getElementById('target-editor-css').value;
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
if (!name) {
@@ -182,13 +166,8 @@ export async function saveTargetEditor() {
const payload = {
name,
device_id: deviceId,
picture_source_id: sourceId,
settings: {
fps: fps,
interpolation_mode: interpolation,
smoothing: smoothing,
standby_interval: standbyInterval,
},
color_strip_source_id: cssId,
standby_interval: standbyInterval,
};
try {
@@ -243,10 +222,11 @@ export async function loadTargetsTab() {
if (!container) return;
try {
// Fetch devices, targets, sources, and pattern templates in parallel
const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([
// Fetch devices, targets, CSS sources, picture sources, and pattern templates in parallel
const [devicesResp, targetsResp, cssResp, psResp, patResp] = await Promise.all([
fetchWithAuth('/devices'),
fetchWithAuth('/picture-targets'),
fetchWithAuth('/color-strip-sources').catch(() => null),
fetchWithAuth('/picture-sources').catch(() => null),
fetchWithAuth('/pattern-templates').catch(() => null),
]);
@@ -257,10 +237,16 @@ export async function loadTargetsTab() {
const targetsData = await targetsResp.json();
const targets = targetsData.targets || [];
let sourceMap = {};
if (sourcesResp && sourcesResp.ok) {
const srcData = await sourcesResp.json();
(srcData.streams || []).forEach(s => { sourceMap[s.id] = s; });
let colorStripSourceMap = {};
if (cssResp && cssResp.ok) {
const cssData = await cssResp.json();
(cssData.sources || []).forEach(s => { colorStripSourceMap[s.id] = s; });
}
let pictureSourceMap = {};
if (psResp && psResp.ok) {
const psData = await psResp.json();
(psData.streams || []).forEach(s => { pictureSourceMap[s.id] = s; });
}
let patternTemplates = [];
@@ -320,7 +306,7 @@ export async function loadTargetsTab() {
if (activeSubTab === 'wled') activeSubTab = 'led';
const subTabs = [
{ key: 'led', icon: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + ledTargets.length },
{ key: 'led', icon: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + Object.keys(colorStripSourceMap).length + ledTargets.length },
{ key: 'key_colors', icon: '\uD83C\uDFA8', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
];
@@ -331,7 +317,7 @@ export async function loadTargetsTab() {
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
// LED panel: devices section + targets section
// LED panel: devices section + color strip sources section + targets section
const ledPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
<div class="subtab-section">
@@ -343,10 +329,19 @@ export async function loadTargetsTab() {
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.color_strips')}</h3>
<div class="devices-grid">
${Object.values(colorStripSourceMap).map(s => createColorStripCard(s, pictureSourceMap)).join('')}
<div class="template-card add-template-card" onclick="showCSSEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
<div class="devices-grid">
${ledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
${ledTargets.map(target => createTargetCard(target, deviceMap, colorStripSourceMap)).join('')}
<div class="template-card add-template-card" onclick="showTargetEditor()">
<div class="add-template-icon">+</div>
</div>
@@ -360,7 +355,7 @@ export async function loadTargetsTab() {
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
<div class="devices-grid">
${kcTargets.map(target => createKCTargetCard(target, sourceMap, patternTemplateMap)).join('')}
${kcTargets.map(target => createKCTargetCard(target, pictureSourceMap, patternTemplateMap)).join('')}
<div class="template-card add-template-card" onclick="showKCEditor()">
<div class="add-template-icon">+</div>
</div>
@@ -422,17 +417,16 @@ export async function loadTargetsTab() {
}
}
export function createTargetCard(target, deviceMap, sourceMap) {
export function createTargetCard(target, deviceMap, colorStripSourceMap) {
const state = target.state || {};
const metrics = target.metrics || {};
const settings = target.settings || {};
const isProcessing = state.processing || false;
const device = deviceMap[target.device_id];
const source = sourceMap[target.picture_source_id];
const css = colorStripSourceMap[target.color_strip_source_id];
const deviceName = device ? device.name : (target.device_id || 'No device');
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
const cssName = css ? css.name : (target.color_strip_source_id || 'No strip source');
// Health info from target state (forwarded from device)
const devOnline = state.device_online || false;
@@ -455,8 +449,7 @@ export function createTargetCard(target, deviceMap, sourceMap) {
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop" title="${t('targets.fps')}"> ${settings.fps || 30}</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.source')}">📺 ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.color_strip_source')}">🎞️ ${escapeHtml(cssName)}</span>
</div>
<div class="card-content">
${isProcessing ? `