|
|
|
|
@@ -74,73 +74,9 @@ function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) {
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Segment editor state ---
|
|
|
|
|
// --- Editor state ---
|
|
|
|
|
let _editorCssSources = []; // populated when editor opens
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* When the selected CSS source is a mapped type, collapse the segment UI
|
|
|
|
|
* to a single source dropdown — range fields, reverse, header, and "Add Segment"
|
|
|
|
|
* are hidden because the mapped CSS already defines spatial zones internally.
|
|
|
|
|
*/
|
|
|
|
|
export function syncSegmentsMappedMode() {
|
|
|
|
|
const list = document.getElementById('target-editor-segment-list');
|
|
|
|
|
if (!list) return;
|
|
|
|
|
const rows = list.querySelectorAll('.segment-row');
|
|
|
|
|
if (rows.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const firstSelect = rows[0].querySelector('.segment-css-select');
|
|
|
|
|
const selectedId = firstSelect ? firstSelect.value : '';
|
|
|
|
|
const selectedSource = _editorCssSources.find(s => s.id === selectedId);
|
|
|
|
|
const isMapped = selectedSource && selectedSource.source_type === 'mapped';
|
|
|
|
|
|
|
|
|
|
// Remove extra segments when switching to mapped
|
|
|
|
|
if (isMapped && rows.length > 1) {
|
|
|
|
|
for (let i = rows.length - 1; i >= 1; i--) rows[i].remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Toggle visibility of range/reverse/header within the first row
|
|
|
|
|
const firstRow = list.querySelector('.segment-row');
|
|
|
|
|
if (firstRow) {
|
|
|
|
|
const header = firstRow.querySelector('.segment-row-header');
|
|
|
|
|
const rangeFields = firstRow.querySelector('.segment-range-fields');
|
|
|
|
|
const reverseLabel = firstRow.querySelector('.segment-reverse-label');
|
|
|
|
|
if (header) header.style.display = isMapped ? 'none' : '';
|
|
|
|
|
if (rangeFields) rangeFields.style.display = isMapped ? 'none' : '';
|
|
|
|
|
if (reverseLabel) reverseLabel.style.display = isMapped ? 'none' : '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hide/show "Add Segment" button
|
|
|
|
|
const addBtn = document.querySelector('#target-editor-segments-group > .btn-sm');
|
|
|
|
|
if (addBtn) addBtn.style.display = isMapped ? 'none' : '';
|
|
|
|
|
|
|
|
|
|
// Swap label: "Segments:" ↔ "Color Strip Source:"
|
|
|
|
|
const group = document.getElementById('target-editor-segments-group');
|
|
|
|
|
if (group) {
|
|
|
|
|
const label = group.querySelector('.label-row label');
|
|
|
|
|
const hintToggle = group.querySelector('.hint-toggle');
|
|
|
|
|
const hint = group.querySelector('.input-hint');
|
|
|
|
|
if (label) label.textContent = isMapped
|
|
|
|
|
? t('targets.color_strip_source')
|
|
|
|
|
: t('targets.segments');
|
|
|
|
|
if (hintToggle) hintToggle.style.display = isMapped ? 'none' : '';
|
|
|
|
|
if (hint) hint.style.display = 'none'; // collapse hint on switch
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
@@ -150,7 +86,7 @@ class TargetEditorModal extends Modal {
|
|
|
|
|
return {
|
|
|
|
|
name: document.getElementById('target-editor-name').value,
|
|
|
|
|
device: document.getElementById('target-editor-device').value,
|
|
|
|
|
segments: _serializeSegments(),
|
|
|
|
|
css_source: document.getElementById('target-editor-css-source').value,
|
|
|
|
|
fps: document.getElementById('target-editor-fps').value,
|
|
|
|
|
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
|
|
|
|
|
};
|
|
|
|
|
@@ -166,9 +102,8 @@ function _autoGenerateTargetName() {
|
|
|
|
|
if (document.getElementById('target-editor-id').value) return;
|
|
|
|
|
const deviceSelect = document.getElementById('target-editor-device');
|
|
|
|
|
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
|
|
|
|
|
// Use first segment's CSS name
|
|
|
|
|
const firstCssSelect = document.querySelector('.segment-css-select');
|
|
|
|
|
const cssName = firstCssSelect?.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}`;
|
|
|
|
|
}
|
|
|
|
|
@@ -210,54 +145,11 @@ 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>`
|
|
|
|
|
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('');
|
|
|
|
|
|
|
|
|
|
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(); syncSegmentsMappedMode()">${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, cloneData = null) {
|
|
|
|
|
@@ -286,10 +178,6 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
|
|
|
|
deviceSelect.appendChild(opt);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Clear segment list
|
|
|
|
|
const segmentList = document.getElementById('target-editor-segment-list');
|
|
|
|
|
segmentList.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
if (targetId) {
|
|
|
|
|
// Editing existing target
|
|
|
|
|
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
|
|
|
|
|
@@ -306,13 +194,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
|
|
|
|
document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.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));
|
|
|
|
|
}
|
|
|
|
|
_populateCssDropdown(target.color_strip_source_id || '');
|
|
|
|
|
} else if (cloneData) {
|
|
|
|
|
// Cloning — create mode but pre-filled from clone data
|
|
|
|
|
document.getElementById('target-editor-id').value = '';
|
|
|
|
|
@@ -325,14 +207,9 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
|
|
|
|
document.getElementById('target-editor-keepalive-interval-value').textContent = cloneData.keepalive_interval ?? 1.0;
|
|
|
|
|
document.getElementById('target-editor-title').textContent = t('targets.add');
|
|
|
|
|
|
|
|
|
|
const segments = cloneData.segments || [];
|
|
|
|
|
if (segments.length === 0) {
|
|
|
|
|
addTargetSegment();
|
|
|
|
|
} else {
|
|
|
|
|
segments.forEach(seg => addTargetSegment(seg));
|
|
|
|
|
}
|
|
|
|
|
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
|
|
|
|
} else {
|
|
|
|
|
// Creating new target — start with one empty segment
|
|
|
|
|
// Creating new target
|
|
|
|
|
document.getElementById('target-editor-id').value = '';
|
|
|
|
|
document.getElementById('target-editor-name').value = '';
|
|
|
|
|
document.getElementById('target-editor-fps').value = 30;
|
|
|
|
|
@@ -340,16 +217,16 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
|
|
|
|
document.getElementById('target-editor-keepalive-interval').value = 1.0;
|
|
|
|
|
document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0';
|
|
|
|
|
document.getElementById('target-editor-title').textContent = t('targets.add');
|
|
|
|
|
addTargetSegment();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
syncSegmentsMappedMode();
|
|
|
|
|
_populateCssDropdown('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Auto-name generation
|
|
|
|
|
_targetNameManuallyEdited = !!(targetId || cloneData);
|
|
|
|
|
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
|
|
|
|
window._targetAutoName = _autoGenerateTargetName;
|
|
|
|
|
deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
|
|
|
|
|
document.getElementById('target-editor-css-source').onchange = () => { _autoGenerateTargetName(); };
|
|
|
|
|
if (!targetId && !cloneData) _autoGenerateTargetName();
|
|
|
|
|
|
|
|
|
|
// Show/hide standby interval based on selected device capabilities
|
|
|
|
|
@@ -392,27 +269,12 @@ 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 colorStripSourceId = document.getElementById('target-editor-css-source').value;
|
|
|
|
|
|
|
|
|
|
const payload = {
|
|
|
|
|
name,
|
|
|
|
|
device_id: deviceId,
|
|
|
|
|
segments,
|
|
|
|
|
color_strip_source_id: colorStripSourceId,
|
|
|
|
|
fps,
|
|
|
|
|
keepalive_interval: standbyInterval,
|
|
|
|
|
};
|
|
|
|
|
@@ -693,17 +555,10 @@ 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(', ');
|
|
|
|
|
function _cssSourceName(cssId, colorStripSourceMap) {
|
|
|
|
|
if (!cssId) return t('targets.no_css');
|
|
|
|
|
const css = colorStripSourceMap[cssId];
|
|
|
|
|
return css ? escapeHtml(css.name) : escapeHtml(cssId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
|
|
|
|
@@ -715,13 +570,12 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
|
|
|
|
const device = deviceMap[target.device_id];
|
|
|
|
|
const deviceName = device ? device.name : (target.device_id || 'No device');
|
|
|
|
|
|
|
|
|
|
const segments = target.segments || [];
|
|
|
|
|
const segSummary = _segmentsSummary(segments, colorStripSourceMap);
|
|
|
|
|
const cssId = target.color_strip_source_id || '';
|
|
|
|
|
const cssSummary = _cssSourceName(cssId, 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';
|
|
|
|
|
// 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;
|
|
|
|
|
@@ -745,7 +599,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.segments')}">🎞️ ${segSummary}</span>
|
|
|
|
|
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.color_strip_source')}">🎞️ ${cssSummary}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card-content">
|
|
|
|
|
${isProcessing ? `
|
|
|
|
|
|