Add multi-segment LED targets, replace single color strip source + skip fields

Each target now has a segments list where each segment maps a color strip
source to a pixel range (start/end) on the device with optional reverse.
This enables composing multiple visualizations on a single LED strip.
Old targets auto-migrate from the single source format on load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 12:49:26 +03:00
parent bbd2ac9910
commit 9d593379b8
14 changed files with 593 additions and 368 deletions

View File

@@ -231,6 +231,92 @@
width: 100%;
}
/* Segment rows in target editor */
.segment-row {
border: 1px solid var(--border-color, #333);
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 6px;
background: var(--card-bg, #1e1e1e);
}
.segment-row-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.segment-index-label {
font-size: 0.8rem;
font-weight: 600;
color: #888;
}
.btn-icon-inline {
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 0 4px;
line-height: 1;
}
.btn-danger-text {
color: var(--danger-color, #f44336);
}
.btn-danger-text:hover {
color: #ff6659;
}
.segment-row-fields {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.segment-row-fields select {
flex: 1 1 100%;
}
.segment-range-fields {
display: flex;
gap: 6px;
align-items: center;
flex: 1;
}
.segment-range-fields label {
font-size: 0.82rem;
color: #aaa;
white-space: nowrap;
}
.segment-range-fields input[type="number"] {
width: 70px;
}
.segment-reverse-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.85rem;
color: #aaa;
cursor: pointer;
white-space: nowrap;
}
.segment-reverse-label input[type="checkbox"] {
margin: 0;
}
.btn-sm {
font-size: 0.85rem;
padding: 4px 10px;
}
.fps-hint {
display: block;
margin-top: 4px;

View File

@@ -82,6 +82,7 @@ import {
import {
loadTargetsTab, loadTargets, switchTargetSubTab,
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
addTargetSegment, removeTargetSegment,
startTargetProcessing, stopTargetProcessing,
startTargetOverlay, stopTargetOverlay, deleteTarget,
} from './features/targets.js';
@@ -265,6 +266,8 @@ Object.assign(window, {
closeTargetEditorModal,
forceCloseTargetEditorModal,
saveTargetEditor,
addTargetSegment,
removeTargetSegment,
startTargetProcessing,
stopTargetProcessing,
startTargetOverlay,

View File

@@ -447,9 +447,15 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
if (device) {
subtitleParts.push((device.device_type || '').toUpperCase());
}
const cssSource = target.color_strip_source_id ? cssSourceMap[target.color_strip_source_id] : null;
if (cssSource) {
subtitleParts.push(t(`color_strip.type.${cssSource.source_type}`) || cssSource.source_type);
const segments = target.segments || [];
if (segments.length > 0) {
const firstCss = cssSourceMap[segments[0].color_strip_source_id];
if (firstCss) {
subtitleParts.push(t(`color_strip.type.${firstCss.source_type}`) || firstCss.source_type);
}
if (segments.length > 1) {
subtitleParts.push(`${segments.length} seg`);
}
}
}

View File

@@ -74,6 +74,23 @@ function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) {
});
}
// --- Segment editor state ---
let _editorCssSources = []; // populated when editor opens
function _serializeSegments() {
const rows = document.querySelectorAll('.segment-row');
const segments = [];
rows.forEach(row => {
segments.push({
css: row.querySelector('.segment-css-select').value,
start: row.querySelector('.segment-start').value,
end: row.querySelector('.segment-end').value,
reverse: row.querySelector('.segment-reverse').checked,
});
});
return JSON.stringify(segments);
}
class TargetEditorModal extends Modal {
constructor() {
super('target-editor-modal');
@@ -83,11 +100,9 @@ class TargetEditorModal extends Modal {
return {
name: document.getElementById('target-editor-name').value,
device: document.getElementById('target-editor-device').value,
css: document.getElementById('target-editor-css').value,
segments: _serializeSegments(),
fps: document.getElementById('target-editor-fps').value,
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
led_skip_start: document.getElementById('target-editor-skip-start').value,
led_skip_end: document.getElementById('target-editor-skip-end').value,
};
}
}
@@ -100,9 +115,10 @@ function _autoGenerateTargetName() {
if (_targetNameManuallyEdited) return;
if (document.getElementById('target-editor-id').value) return;
const deviceSelect = document.getElementById('target-editor-device');
const cssSelect = document.getElementById('target-editor-css');
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
const cssName = cssSelect.selectedOptions[0]?.dataset?.name || '';
// Use first segment's CSS name
const firstCssSelect = document.querySelector('.segment-css-select');
const cssName = firstCssSelect?.selectedOptions[0]?.dataset?.name || '';
if (!deviceName || !cssName) return;
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
}
@@ -132,6 +148,56 @@ function _updateKeepaliveVisibility() {
keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none';
}
export function addTargetSegment(segment = null) {
const list = document.getElementById('target-editor-segment-list');
const index = list.querySelectorAll('.segment-row').length;
const row = document.createElement('div');
row.className = 'segment-row';
row.innerHTML = _renderSegmentRowInner(index, segment);
list.appendChild(row);
}
export function removeTargetSegment(btn) {
const row = btn.closest('.segment-row');
row.remove();
// Re-index labels
document.querySelectorAll('.segment-row').forEach((r, i) => {
const label = r.querySelector('.segment-index-label');
if (label) label.textContent = `#${i + 1}`;
});
}
function _renderSegmentRowInner(index, segment) {
const cssId = segment?.color_strip_source_id || '';
const start = segment?.start ?? 0;
const end = segment?.end ?? 0;
const reverse = segment?.reverse || false;
const options = _editorCssSources.map(s =>
`<option value="${s.id}" data-name="${escapeHtml(s.name)}" ${s.id === cssId ? 'selected' : ''}>\uD83C\uDFAC ${escapeHtml(s.name)}</option>`
).join('');
return `
<div class="segment-row-header">
<span class="segment-index-label">#${index + 1}</span>
<button type="button" class="btn-icon-inline btn-danger-text" onclick="removeTargetSegment(this)" title="${t('targets.segment.remove')}">&times;</button>
</div>
<div class="segment-row-fields">
<select class="segment-css-select" onchange="window._targetAutoName && window._targetAutoName()">${options}</select>
<div class="segment-range-fields">
<label>${t('targets.segment.start')}</label>
<input type="number" class="segment-start" min="0" value="${start}" placeholder="0">
<label>${t('targets.segment.end')}</label>
<input type="number" class="segment-end" min="0" value="${end}" placeholder="0 = auto">
</div>
<label class="segment-reverse-label">
<input type="checkbox" class="segment-reverse" ${reverse ? 'checked' : ''}>
<span>${t('targets.segment.reverse')}</span>
</label>
</div>
`;
}
export async function showTargetEditor(targetId = null) {
try {
// Load devices and CSS sources for dropdowns
@@ -143,6 +209,7 @@ export async function showTargetEditor(targetId = null) {
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
set_targetEditorDevices(devices);
_editorCssSources = cssSources;
// Populate device select
const deviceSelect = document.getElementById('target-editor-device');
@@ -157,16 +224,9 @@ export async function showTargetEditor(targetId = null) {
deviceSelect.appendChild(opt);
});
// 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;
opt.textContent = `🎞️ ${s.name}`;
cssSelect.appendChild(opt);
});
// Clear segment list
const segmentList = document.getElementById('target-editor-segment-list');
segmentList.innerHTML = '';
if (targetId) {
// Editing existing target
@@ -177,33 +237,37 @@ 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 || '';
cssSelect.value = target.color_strip_source_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-skip-start').value = target.led_skip_start ?? 0;
document.getElementById('target-editor-skip-end').value = target.led_skip_end ?? 0;
document.getElementById('target-editor-title').textContent = t('targets.edit');
// Populate segments
const segments = target.segments || [];
if (segments.length === 0) {
addTargetSegment();
} else {
segments.forEach(seg => addTargetSegment(seg));
}
} else {
// Creating new target — first option is selected by default
// Creating new target — start with one empty segment
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-skip-start').value = 0;
document.getElementById('target-editor-skip-end').value = 0;
document.getElementById('target-editor-title').textContent = t('targets.add');
addTargetSegment();
}
// Auto-name generation
_targetNameManuallyEdited = !!targetId;
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
window._targetAutoName = _autoGenerateTargetName;
deviceSelect.onchange = () => { _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
cssSelect.onchange = () => _autoGenerateTargetName();
if (!targetId) _autoGenerateTargetName();
// Show/hide standby interval based on selected device capabilities
@@ -237,10 +301,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 cssId = document.getElementById('target-editor-css').value;
const standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value);
const ledSkipStart = parseInt(document.getElementById('target-editor-skip-start').value) || 0;
const ledSkipEnd = parseInt(document.getElementById('target-editor-skip-end').value) || 0;
if (!name) {
targetEditorModal.showError(t('targets.error.name_required'));
@@ -249,14 +310,28 @@ export async function saveTargetEditor() {
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
// Collect segments from DOM
const segmentRows = document.querySelectorAll('.segment-row');
const segments = [];
for (const row of segmentRows) {
const cssId = row.querySelector('.segment-css-select').value;
const start = parseInt(row.querySelector('.segment-start').value) || 0;
const end = parseInt(row.querySelector('.segment-end').value) || 0;
const reverse = row.querySelector('.segment-reverse').checked;
segments.push({
color_strip_source_id: cssId,
start,
end,
reverse,
});
}
const payload = {
name,
device_id: deviceId,
color_strip_source_id: cssId,
segments,
fps,
keepalive_interval: standbyInterval,
led_skip_start: ledSkipStart,
led_skip_end: ledSkipEnd,
};
try {
@@ -535,6 +610,19 @@ export async function loadTargetsTab() {
}
}
function _segmentsSummary(segments, colorStripSourceMap) {
if (!segments || segments.length === 0) return t('targets.no_segments');
return segments.map(seg => {
const css = colorStripSourceMap[seg.color_strip_source_id];
const name = css ? css.name : (seg.color_strip_source_id || '?');
let range = '';
if (seg.start || seg.end) {
range = ` [${seg.start}-${seg.end || '\u221e'}]`;
}
return escapeHtml(name) + range;
}).join(', ');
}
export function createTargetCard(target, deviceMap, colorStripSourceMap) {
const state = target.state || {};
const metrics = target.metrics || {};
@@ -542,9 +630,15 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
const isProcessing = state.processing || false;
const device = deviceMap[target.device_id];
const css = colorStripSourceMap[target.color_strip_source_id];
const deviceName = device ? device.name : (target.device_id || 'No device');
const cssName = css ? css.name : (target.color_strip_source_id || 'No strip source');
const segments = target.segments || [];
const segSummary = _segmentsSummary(segments, colorStripSourceMap);
// Determine if overlay is available (first segment has a picture-based CSS)
const firstCssId = segments.length > 0 ? segments[0].color_strip_source_id : '';
const firstCss = firstCssId ? colorStripSourceMap[firstCssId] : null;
const overlayAvailable = !firstCss || firstCss.source_type === 'picture';
// Health info from target state (forwarded from device)
const devOnline = state.device_online || false;
@@ -568,7 +662,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
<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')}">⚡ ${target.fps || 30} fps</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.color_strip_source')}">🎞️ ${escapeHtml(cssName)}</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.segments')}">🎞️ ${segSummary}</span>
</div>
<div class="card-content">
${isProcessing ? `
@@ -631,7 +725,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
<button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
✏️
</button>
${(!css || css.source_type === 'picture') ? (state.overlay_active ? `
${overlayAvailable ? (state.overlay_active ? `
<button class="btn btn-icon btn-warning" onclick="stopTargetOverlay('${target.id}')" title="${t('overlay.button.hide')}">
👁️
</button>

View File

@@ -356,8 +356,14 @@
"targets.device": "Device:",
"targets.device.hint": "Select the LED device to send data to",
"targets.device.none": "-- Select a device --",
"targets.color_strip_source": "Color Strip Source:",
"targets.color_strip_source.hint": "Color strip source that captures and processes screen pixels into LED colors",
"targets.segments": "Segments:",
"targets.segments.hint": "Each segment maps a color strip source to a pixel range on the LED strip. Gaps between segments stay black. A single segment with Start=0, End=0 auto-fits to the full strip.",
"targets.segments.add": "+ Add Segment",
"targets.segment.start": "Start:",
"targets.segment.end": "End:",
"targets.segment.reverse": "Reverse",
"targets.segment.remove": "Remove segment",
"targets.no_segments": "No segments",
"targets.source": "Source:",
"targets.source.hint": "Which picture source to capture and process",
"targets.source.none": "-- No source assigned --",
@@ -373,10 +379,6 @@
"targets.interpolation.dominant": "Dominant",
"targets.smoothing": "Smoothing:",
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
"targets.led_skip": "LED Skip:",
"targets.led_skip.hint": "Number of LEDs at the start and end of the strip to keep black. Color sources will render only across the active (non-skipped) LEDs.",
"targets.led_skip_start": "Start:",
"targets.led_skip_end": "End:",
"targets.keepalive_interval": "Keep Alive Interval:",
"targets.keepalive_interval.hint": "How often to resend the last frame when the source is static, keeping the device in live mode (0.5-5.0s)",
"targets.created": "Target created successfully",

View File

@@ -356,8 +356,14 @@
"targets.device": "Устройство:",
"targets.device.hint": "Выберите LED устройство для передачи данных",
"targets.device.none": "-- Выберите устройство --",
"targets.color_strip_source": "Источник цветовой полосы:",
"targets.color_strip_source.hint": "Источник цветовой полосы, который захватывает и обрабатывает пиксели экрана в цвета светодиодов",
"targets.segments": "Сегменты:",
"targets.segments.hint": "Каждый сегмент отображает источник цветовой полосы на диапазон пикселей LED ленты. Промежутки между сегментами остаются чёрными. Один сегмент с Начало=0, Конец=0 авто-подгоняется под всю ленту.",
"targets.segments.add": "+ Добавить сегмент",
"targets.segment.start": "Начало:",
"targets.segment.end": "Конец:",
"targets.segment.reverse": "Реверс",
"targets.segment.remove": "Удалить сегмент",
"targets.no_segments": "Нет сегментов",
"targets.source": "Источник:",
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
"targets.source.none": "-- Источник не назначен --",
@@ -373,10 +379,6 @@
"targets.interpolation.dominant": "Доминантный",
"targets.smoothing": "Сглаживание:",
"targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
"targets.led_skip": "Пропуск LED:",
"targets.led_skip.hint": "Количество светодиодов в начале и конце ленты, которые остаются чёрными. Источники цвета будут рендериться только на активных (непропущенных) LED.",
"targets.led_skip_start": "Начало:",
"targets.led_skip_end": "Конец:",
"targets.keepalive_interval": "Интервал поддержания связи:",
"targets.keepalive_interval.hint": "Как часто повторно отправлять последний кадр при статичном источнике для удержания устройства в режиме live (0.5-5.0с)",
"targets.created": "Цель успешно создана",