Add audio sources as first-class entities, add mapped CSS type, simplify target editor for mapped sources

- Audio sources moved to separate tab with dedicated CRUD API, store, and editor modal
- New "mapped" color strip source type: assigns different CSS sources to distinct LED sub-ranges (zones)
- Mapped stream runtime with per-zone sub-streams, auto-sizing, hot-update support
- Target editor auto-collapses segments UI when mapped CSS is selected
- Delete protection for CSS sources referenced by mapped zones
- Compact header/footer layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 23:35:58 +03:00
parent 199039326b
commit 9efb08acb6
28 changed files with 1729 additions and 153 deletions

View File

@@ -0,0 +1,193 @@
/**
* Audio Sources — CRUD for multichannel and mono audio sources.
*
* Audio sources are managed entities that encapsulate audio device
* configuration. Multichannel sources represent physical audio devices;
* mono sources extract a single channel from a multichannel source.
* CSS audio type references a mono source by ID.
*
* Card rendering is handled by streams.js (Audio tab).
* This module manages the editor modal and API operations.
*/
import { _cachedAudioSources, set_cachedAudioSources } from '../core/state.js';
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';
import { loadPictureSources } from './streams.js';
const audioSourceModal = new Modal('audio-source-modal');
// ── Modal ─────────────────────────────────────────────────────
export async function showAudioSourceModal(sourceType, editData) {
const isEdit = !!editData;
const titleKey = isEdit
? (editData.source_type === 'mono' ? 'audio_source.edit.mono' : 'audio_source.edit.multichannel')
: (sourceType === 'mono' ? 'audio_source.add.mono' : 'audio_source.add.multichannel');
document.getElementById('audio-source-modal-title').textContent = t(titleKey);
document.getElementById('audio-source-id').value = isEdit ? editData.id : '';
document.getElementById('audio-source-error').style.display = 'none';
const typeSelect = document.getElementById('audio-source-type');
typeSelect.value = isEdit ? editData.source_type : sourceType;
typeSelect.disabled = isEdit; // can't change type after creation
onAudioSourceTypeChange();
if (isEdit) {
document.getElementById('audio-source-name').value = editData.name || '';
document.getElementById('audio-source-description').value = editData.description || '';
if (editData.source_type === 'multichannel') {
await _loadAudioDevices();
_selectAudioDevice(editData.device_index, editData.is_loopback);
} else {
_loadMultichannelSources(editData.audio_source_id);
document.getElementById('audio-source-channel').value = editData.channel || 'mono';
}
} else {
document.getElementById('audio-source-name').value = '';
document.getElementById('audio-source-description').value = '';
if (sourceType === 'multichannel') {
await _loadAudioDevices();
} else {
_loadMultichannelSources();
}
}
audioSourceModal.open();
}
export function closeAudioSourceModal() {
audioSourceModal.forceClose();
}
export function onAudioSourceTypeChange() {
const type = document.getElementById('audio-source-type').value;
document.getElementById('audio-source-multichannel-section').style.display = type === 'multichannel' ? '' : 'none';
document.getElementById('audio-source-mono-section').style.display = type === 'mono' ? '' : 'none';
}
// ── Save ──────────────────────────────────────────────────────
export async function saveAudioSource() {
const id = document.getElementById('audio-source-id').value;
const name = document.getElementById('audio-source-name').value.trim();
const sourceType = document.getElementById('audio-source-type').value;
const description = document.getElementById('audio-source-description').value.trim() || null;
const errorEl = document.getElementById('audio-source-error');
if (!name) {
errorEl.textContent = t('audio_source.error.name_required');
errorEl.style.display = '';
return;
}
const payload = { name, source_type: sourceType, description };
if (sourceType === 'multichannel') {
const deviceVal = document.getElementById('audio-source-device').value || '-1:1';
const [devIdx, devLoop] = deviceVal.split(':');
payload.device_index = parseInt(devIdx) || -1;
payload.is_loopback = devLoop !== '0';
} else {
payload.audio_source_id = document.getElementById('audio-source-parent').value;
payload.channel = document.getElementById('audio-source-channel').value;
}
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/audio-sources/${id}` : '/audio-sources';
const resp = await fetchWithAuth(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success');
audioSourceModal.forceClose();
await loadPictureSources();
} catch (e) {
errorEl.textContent = e.message;
errorEl.style.display = '';
}
}
// ── Edit ──────────────────────────────────────────────────────
export async function editAudioSource(sourceId) {
try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
await showAudioSourceModal(data.source_type, data);
} catch (e) {
showToast(e.message, 'error');
}
}
// ── Delete ────────────────────────────────────────────────────
export async function deleteAudioSource(sourceId) {
const confirmed = await showConfirm(t('audio_source.delete.confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('audio_source.deleted'), 'success');
await loadPictureSources();
} catch (e) {
showToast(e.message, 'error');
}
}
// ── Helpers ───────────────────────────────────────────────────
async function _loadAudioDevices() {
const select = document.getElementById('audio-source-device');
if (!select) return;
try {
const resp = await fetchWithAuth('/audio-devices');
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
const devices = data.devices || [];
select.innerHTML = devices.map(d => {
const label = d.is_loopback ? `🔊 ${d.name}` : `🎤 ${d.name}`;
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
return `<option value="${val}">${escapeHtml(label)}</option>`;
}).join('');
if (devices.length === 0) {
select.innerHTML = '<option value="-1:1">Default</option>';
}
} catch {
select.innerHTML = '<option value="-1:1">Default</option>';
}
}
function _selectAudioDevice(deviceIndex, isLoopback) {
const select = document.getElementById('audio-source-device');
if (!select) return;
const val = `${deviceIndex ?? -1}:${isLoopback !== false ? '1' : '0'}`;
const opt = Array.from(select.options).find(o => o.value === val);
if (opt) select.value = val;
}
function _loadMultichannelSources(selectedId) {
const select = document.getElementById('audio-source-parent');
if (!select) return;
const multichannel = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
select.innerHTML = multichannel.map(s =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
}

View File

@@ -39,8 +39,9 @@ class CSSEditorModal extends Modal {
effect_scale: document.getElementById('css-editor-effect-scale').value,
effect_mirror: document.getElementById('css-editor-effect-mirror').checked,
composite_layers: JSON.stringify(_compositeLayers),
mapped_zones: JSON.stringify(_mappedZones),
audio_viz: document.getElementById('css-editor-audio-viz').value,
audio_device: document.getElementById('css-editor-audio-device').value,
audio_source: document.getElementById('css-editor-audio-source').value,
audio_sensitivity: document.getElementById('css-editor-audio-sensitivity').value,
audio_smoothing: document.getElementById('css-editor-audio-smoothing').value,
audio_palette: document.getElementById('css-editor-audio-palette').value,
@@ -63,6 +64,7 @@ export function onCSSTypeChange() {
document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none';
document.getElementById('css-editor-effect-section').style.display = type === 'effect' ? '' : 'none';
document.getElementById('css-editor-composite-section').style.display = type === 'composite' ? '' : 'none';
document.getElementById('css-editor-mapped-section').style.display = type === 'mapped' ? '' : 'none';
document.getElementById('css-editor-audio-section').style.display = type === 'audio' ? '' : 'none';
if (type === 'effect') onEffectTypeChange();
@@ -97,14 +99,16 @@ export function onCSSTypeChange() {
}
_syncAnimationSpeedState();
// LED count — not needed for composite/audio (uses device count)
// LED count — not needed for composite/mapped/audio (uses device count)
document.getElementById('css-editor-led-count-group').style.display =
(type === 'composite' || type === 'audio') ? 'none' : '';
(type === 'composite' || type === 'mapped' || type === 'audio') ? 'none' : '';
if (type === 'audio') {
_loadAudioDevices();
_loadAudioSources();
} else if (type === 'composite') {
_compositeRenderList();
} else if (type === 'mapped') {
_mappedRenderList();
} else if (type === 'gradient') {
requestAnimationFrame(() => gradientRenderAll());
}
@@ -391,6 +395,107 @@ function _loadCompositeState(css) {
_compositeRenderList();
}
/* ── Mapped zone helpers ──────────────────────────────────────── */
let _mappedZones = [];
let _mappedAvailableSources = []; // non-mapped sources for zone dropdowns
function _mappedRenderList() {
const list = document.getElementById('mapped-zones-list');
if (!list) return;
list.innerHTML = _mappedZones.map((zone, i) => {
const srcOptions = _mappedAvailableSources.map(s =>
`<option value="${s.id}"${zone.source_id === s.id ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
return `
<div class="segment-row">
<div class="segment-row-header">
<span class="segment-index-label">#${i + 1}</span>
<button type="button" class="btn-icon-inline btn-danger-text"
onclick="mappedRemoveZone(${i})" title="${t('common.delete')}">&times;</button>
</div>
<div class="segment-row-fields">
<select class="mapped-zone-source" data-idx="${i}">${srcOptions}</select>
<div class="segment-range-fields">
<label>${t('color_strip.mapped.zone_start')}</label>
<input type="number" class="mapped-zone-start" data-idx="${i}"
min="0" value="${zone.start}" placeholder="0">
<label>${t('color_strip.mapped.zone_end')}</label>
<input type="number" class="mapped-zone-end" data-idx="${i}"
min="0" value="${zone.end}" placeholder="0 = auto">
</div>
<label class="segment-reverse-label">
<input type="checkbox" class="mapped-zone-reverse" data-idx="${i}"${zone.reverse ? ' checked' : ''}>
<span>${t('color_strip.mapped.zone_reverse')}</span>
</label>
</div>
</div>
`;
}).join('');
}
export function mappedAddZone() {
_mappedZonesSyncFromDom();
_mappedZones.push({
source_id: _mappedAvailableSources.length > 0 ? _mappedAvailableSources[0].id : '',
start: 0,
end: 0,
reverse: false,
});
_mappedRenderList();
}
export function mappedRemoveZone(i) {
_mappedZonesSyncFromDom();
_mappedZones.splice(i, 1);
_mappedRenderList();
}
function _mappedZonesSyncFromDom() {
const list = document.getElementById('mapped-zones-list');
if (!list) return;
const srcs = list.querySelectorAll('.mapped-zone-source');
const starts = list.querySelectorAll('.mapped-zone-start');
const ends = list.querySelectorAll('.mapped-zone-end');
const reverses = list.querySelectorAll('.mapped-zone-reverse');
if (srcs.length === _mappedZones.length) {
for (let i = 0; i < srcs.length; i++) {
_mappedZones[i].source_id = srcs[i].value;
_mappedZones[i].start = parseInt(starts[i].value) || 0;
_mappedZones[i].end = parseInt(ends[i].value) || 0;
_mappedZones[i].reverse = reverses[i].checked;
}
}
}
function _mappedGetZones() {
_mappedZonesSyncFromDom();
return _mappedZones.map(z => ({
source_id: z.source_id,
start: z.start,
end: z.end,
reverse: z.reverse,
}));
}
function _loadMappedState(css) {
const raw = css && css.zones;
_mappedZones = (raw && raw.length > 0)
? raw.map(z => ({
source_id: z.source_id || '',
start: z.start || 0,
end: z.end || 0,
reverse: z.reverse || false,
}))
: [];
_mappedRenderList();
}
function _resetMappedState() {
_mappedZones = [];
_mappedRenderList();
}
/* ── Audio visualization helpers ──────────────────────────────── */
export function onAudioVizChange() {
@@ -405,24 +510,22 @@ export function onAudioVizChange() {
document.getElementById('css-editor-audio-mirror-group').style.display = viz === 'spectrum' ? '' : 'none';
}
async function _loadAudioDevices() {
const select = document.getElementById('css-editor-audio-device');
async function _loadAudioSources() {
const select = document.getElementById('css-editor-audio-source');
if (!select) return;
try {
const resp = await fetchWithAuth('/audio-devices');
const resp = await fetchWithAuth('/audio-sources?source_type=mono');
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
const devices = data.devices || [];
select.innerHTML = devices.map(d => {
const label = d.is_loopback ? `🔊 ${d.name}` : `🎤 ${d.name}`;
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
return `<option value="${val}">${escapeHtml(label)}</option>`;
}).join('');
if (devices.length === 0) {
select.innerHTML = '<option value="-1:1">Default</option>';
const sources = data.sources || [];
select.innerHTML = sources.map(s =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
if (sources.length === 0) {
select.innerHTML = '';
}
} catch {
select.innerHTML = '<option value="-1:1">Default</option>';
select.innerHTML = '';
}
}
@@ -438,21 +541,15 @@ function _loadAudioState(css) {
document.getElementById('css-editor-audio-smoothing').value = smoothing;
document.getElementById('css-editor-audio-smoothing-val').textContent = parseFloat(smoothing).toFixed(2);
document.getElementById('css-editor-audio-channel').value = css.audio_channel || 'mono';
document.getElementById('css-editor-audio-palette').value = css.palette || 'rainbow';
document.getElementById('css-editor-audio-color').value = rgbArrayToHex(css.color || [0, 255, 0]);
document.getElementById('css-editor-audio-color-peak').value = rgbArrayToHex(css.color_peak || [255, 0, 0]);
document.getElementById('css-editor-audio-mirror').checked = css.mirror || false;
// Set audio device selector to match stored values
const deviceIdx = css.audio_device_index ?? -1;
const loopback = css.audio_loopback !== false ? '1' : '0';
const deviceVal = `${deviceIdx}:${loopback}`;
const select = document.getElementById('css-editor-audio-device');
if (select) {
// Try exact match, fall back to first option
const opt = Array.from(select.options).find(o => o.value === deviceVal);
if (opt) select.value = deviceVal;
// Set audio source selector
const select = document.getElementById('css-editor-audio-source');
if (select && css.audio_source_id) {
select.value = css.audio_source_id;
}
}
@@ -462,7 +559,6 @@ function _resetAudioState() {
document.getElementById('css-editor-audio-sensitivity-val').textContent = '1.0';
document.getElementById('css-editor-audio-smoothing').value = 0.3;
document.getElementById('css-editor-audio-smoothing-val').textContent = '0.30';
document.getElementById('css-editor-audio-channel').value = 'mono';
document.getElementById('css-editor-audio-palette').value = 'rainbow';
document.getElementById('css-editor-audio-color').value = '#00ff00';
document.getElementById('css-editor-audio-color-peak').value = '#ff0000';
@@ -477,6 +573,7 @@ export function createColorStripCard(source, pictureSourceMap) {
const isColorCycle = source.source_type === 'color_cycle';
const isEffect = source.source_type === 'effect';
const isComposite = source.source_type === 'composite';
const isMapped = source.source_type === 'mapped';
const isAudio = source.source_type === 'audio';
const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
@@ -543,15 +640,20 @@ export function createColorStripCard(source, pictureSourceMap) {
<span class="stream-card-prop">🔗 ${enabledCount}/${layerCount} ${t('color_strip.composite.layers_count')}</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
`;
} else if (isMapped) {
const zoneCount = (source.zones || []).length;
propsHtml = `
<span class="stream-card-prop">📍 ${zoneCount} ${t('color_strip.mapped.zones_count')}</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
`;
} else if (isAudio) {
const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum';
const sensitivityVal = (source.sensitivity || 1.0).toFixed(1);
const ch = source.audio_channel || 'mono';
const chBadge = ch !== 'mono' ? `<span class="stream-card-prop" title="${t('color_strip.audio.channel')}">${ch === 'left' ? 'L' : 'R'}</span>` : '';
const srcLabel = source.audio_source_id || '';
propsHtml = `
<span class="stream-card-prop">🎵 ${escapeHtml(vizLabel)}</span>
<span class="stream-card-prop" title="${t('color_strip.audio.sensitivity')}">📶 ${sensitivityVal}</span>
${chBadge}
${source.audio_source_id ? `<span class="stream-card-prop" title="${t('color_strip.audio.source')}">🔊</span>` : ''}
${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''}
`;
} else {
@@ -567,8 +669,8 @@ export function createColorStripCard(source, pictureSourceMap) {
`;
}
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : isAudio ? '🎵' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isAudio)
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : isMapped ? '📍' : isAudio ? '🎵' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio)
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
: '';
@@ -605,6 +707,9 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
_compositeAvailableSources = allCssSources.filter(s =>
s.source_type !== 'composite' && (!cssId || s.id !== cssId)
);
_mappedAvailableSources = allCssSources.filter(s =>
s.source_type !== 'mapped' && (!cssId || s.id !== cssId)
);
const sourceSelect = document.getElementById('css-editor-picture-source');
sourceSelect.innerHTML = '';
@@ -648,10 +753,12 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
document.getElementById('css-editor-effect-scale-val').textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
document.getElementById('css-editor-effect-mirror').checked = css.mirror || false;
} else if (sourceType === 'audio') {
await _loadAudioDevices();
await _loadAudioSources();
_loadAudioState(css);
} else if (sourceType === 'composite') {
_loadCompositeState(css);
} else if (sourceType === 'mapped') {
_loadMappedState(css);
} else {
sourceSelect.value = css.picture_source_id || '';
@@ -687,11 +794,15 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
document.getElementById('css-editor-id').value = css.id;
document.getElementById('css-editor-name').value = css.name;
// Exclude self from composite sources when editing
// Exclude self from composite/mapped sources when editing
if (css.source_type === 'composite') {
_compositeAvailableSources = allCssSources.filter(s =>
s.source_type !== 'composite' && s.id !== css.id
);
} else if (css.source_type === 'mapped') {
_mappedAvailableSources = allCssSources.filter(s =>
s.source_type !== 'mapped' && s.id !== css.id
);
}
await _populateFromCSS(css);
@@ -731,6 +842,7 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
document.getElementById('css-editor-effect-scale-val').textContent = '1.0';
document.getElementById('css-editor-effect-mirror').checked = false;
_loadCompositeState(null);
_resetMappedState();
_resetAudioState();
document.getElementById('css-editor-title').textContent = t('color_strip.add');
document.getElementById('css-editor-gradient-preset').value = '';
@@ -820,14 +932,10 @@ export async function saveCSSEditor() {
}
if (!cssId) payload.source_type = 'effect';
} else if (sourceType === 'audio') {
const deviceVal = document.getElementById('css-editor-audio-device').value || '-1:1';
const [devIdx, devLoop] = deviceVal.split(':');
payload = {
name,
visualization_mode: document.getElementById('css-editor-audio-viz').value,
audio_device_index: parseInt(devIdx) || -1,
audio_loopback: devLoop !== '0',
audio_channel: document.getElementById('css-editor-audio-channel').value,
audio_source_id: document.getElementById('css-editor-audio-source').value || null,
sensitivity: parseFloat(document.getElementById('css-editor-audio-sensitivity').value),
smoothing: parseFloat(document.getElementById('css-editor-audio-smoothing').value),
palette: document.getElementById('css-editor-audio-palette').value,
@@ -852,6 +960,15 @@ export async function saveCSSEditor() {
layers,
};
if (!cssId) payload.source_type = 'composite';
} else if (sourceType === 'mapped') {
const zones = _mappedGetZones();
const hasEmpty = zones.some(z => !z.source_id);
if (hasEmpty) {
cssEditorModal.showError(t('color_strip.mapped.error.no_source'));
return;
}
payload = { name, zones };
if (!cssId) payload.source_type = 'mapped';
} else {
payload = {
name,

View File

@@ -18,6 +18,7 @@ import {
_currentTestStreamId, set_currentTestStreamId,
_currentTestPPTemplateId, set_currentTestPPTemplateId,
_lastValidatedImageSource, set_lastValidatedImageSource,
_cachedAudioSources, set_cachedAudioSources,
apiKey,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
@@ -436,11 +437,12 @@ export async function deleteTemplate(templateId) {
export async function loadPictureSources() {
try {
const [filtersResp, ppResp, captResp, streamsResp] = await Promise.all([
const [filtersResp, ppResp, captResp, streamsResp, audioResp] = await Promise.all([
_availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
fetchWithAuth('/postprocessing-templates'),
fetchWithAuth('/capture-templates'),
fetchWithAuth('/picture-sources')
fetchWithAuth('/picture-sources'),
fetchWithAuth('/audio-sources'),
]);
if (filtersResp && filtersResp.ok) {
@@ -455,6 +457,10 @@ export async function loadPictureSources() {
const cd = await captResp.json();
set_cachedCaptureTemplates(cd.templates || []);
}
if (audioResp && audioResp.ok) {
const ad = await audioResp.json();
set_cachedAudioSources(ad.sources || []);
}
if (!streamsResp.ok) throw new Error(`Failed to load streams: ${streamsResp.status}`);
const data = await streamsResp.json();
set_cachedStreams(data.streams || []);
@@ -596,21 +602,60 @@ function renderPictureSourcesList(streams) {
const processedStreams = streams.filter(s => s.stream_type === 'processed');
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
const multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono');
const addStreamCard = (type) => `
<div class="template-card add-template-card" onclick="showAddStreamModal('${type}')">
<div class="add-template-icon">+</div>
</div>`;
const tabs = [
{ key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', streams: rawStreams },
{ key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', streams: staticImageStreams },
{ key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', streams: processedStreams },
{ key: 'raw', icon: '🖥️', titleKey: 'streams.group.raw', count: rawStreams.length },
{ key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', count: staticImageStreams.length },
{ key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', count: processedStreams.length },
{ key: 'audio', icon: '🔊', titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
];
const tabBar = `<div class="stream-tab-bar">${tabs.map(tab =>
`<button class="stream-tab-btn${tab.key === activeTab ? ' active' : ''}" data-stream-tab="${tab.key}" onclick="switchStreamTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.streams.length}</span></button>`
`<button class="stream-tab-btn${tab.key === activeTab ? ' active' : ''}" data-stream-tab="${tab.key}" onclick="switchStreamTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
).join('')}</div>`;
const renderAudioSourceCard = (src) => {
const isMono = src.source_type === 'mono';
const icon = isMono ? '🎤' : '🔊';
let propsHtml = '';
if (isMono) {
const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id);
const parentName = parent ? parent.name : src.audio_source_id;
const chLabel = src.channel === 'left' ? 'L' : src.channel === 'right' ? 'R' : 'M';
propsHtml = `
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.parent'))}">${escapeHtml(parentName)}</span>
<span class="stream-card-prop" title="${escapeHtml(t('audio_source.channel'))}">${chLabel}</span>
`;
} else {
const devIdx = src.device_index ?? -1;
const loopback = src.is_loopback !== false;
const devLabel = loopback ? '🔊 Loopback' : '🎤 Input';
propsHtml = `<span class="stream-card-prop">${devLabel} #${devIdx}</span>`;
}
return `
<div class="template-card" data-id="${src.id}">
<button class="card-remove-btn" onclick="deleteAudioSource('${src.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
</div>
<div class="stream-card-props">${propsHtml}</div>
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="editAudioSource('${src.id}')" title="${t('common.edit')}">✏️</button>
</div>
</div>
`;
};
const panels = tabs.map(tab => {
let panelContent = '';
@@ -619,7 +664,7 @@ function renderPictureSourcesList(streams) {
<div class="subtab-section">
<h3 class="subtab-section-header">${t('streams.section.streams')}</h3>
<div class="templates-grid">
${tab.streams.map(renderStreamCard).join('')}
${rawStreams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>
</div>
@@ -637,7 +682,7 @@ function renderPictureSourcesList(streams) {
<div class="subtab-section">
<h3 class="subtab-section-header">${t('streams.section.streams')}</h3>
<div class="templates-grid">
${tab.streams.map(renderStreamCard).join('')}
${processedStreams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>
</div>
@@ -650,10 +695,30 @@ function renderPictureSourcesList(streams) {
</div>
</div>
</div>`;
} else if (tab.key === 'audio') {
panelContent = `
<div class="subtab-section">
<h3 class="subtab-section-header">${t('audio_source.group.multichannel')}</h3>
<div class="templates-grid">
${multichannelSources.map(renderAudioSourceCard).join('')}
<div class="template-card add-template-card" onclick="showAudioSourceModal('multichannel')">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('audio_source.group.mono')}</h3>
<div class="templates-grid">
${monoSources.map(renderAudioSourceCard).join('')}
<div class="template-card add-template-card" onclick="showAudioSourceModal('mono')">
<div class="add-template-icon">+</div>
</div>
</div>
</div>`;
} else {
panelContent = `
<div class="templates-grid">
${tab.streams.map(renderStreamCard).join('')}
${staticImageStreams.map(renderStreamCard).join('')}
${addStreamCard(tab.key)}
</div>`;
}

View File

@@ -77,6 +77,56 @@ function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) {
// --- Segment 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 = [];
@@ -195,7 +245,7 @@ function _renderSegmentRowInner(index, segment) {
<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>
<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">
@@ -293,6 +343,8 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
addTargetSegment();
}
syncSegmentsMappedMode();
// Auto-name generation
_targetNameManuallyEdited = !!(targetId || cloneData);
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };