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

@@ -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>`;
}