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