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:
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')}">×</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>
|
||||
|
||||
Reference in New Issue
Block a user