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:
@@ -85,7 +85,7 @@ import {
|
||||
import {
|
||||
loadTargetsTab, loadTargets, switchTargetSubTab,
|
||||
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
|
||||
addTargetSegment, removeTargetSegment,
|
||||
addTargetSegment, removeTargetSegment, syncSegmentsMappedMode,
|
||||
startTargetProcessing, stopTargetProcessing,
|
||||
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
||||
cloneTarget,
|
||||
@@ -97,11 +97,18 @@ import {
|
||||
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, updateEffectPreview,
|
||||
colorCycleAddColor, colorCycleRemoveColor,
|
||||
compositeAddLayer, compositeRemoveLayer,
|
||||
mappedAddZone, mappedRemoveZone,
|
||||
onAudioVizChange,
|
||||
applyGradientPreset,
|
||||
cloneColorStrip,
|
||||
} from './features/color-strips.js';
|
||||
|
||||
// Layer 5: audio sources
|
||||
import {
|
||||
showAudioSourceModal, closeAudioSourceModal, saveAudioSource,
|
||||
editAudioSource, deleteAudioSource, onAudioSourceTypeChange,
|
||||
} from './features/audio-sources.js';
|
||||
|
||||
// Layer 5: calibration
|
||||
import {
|
||||
showCalibration, closeCalibrationModal, forceCloseCalibrationModal, saveCalibration,
|
||||
@@ -278,6 +285,7 @@ Object.assign(window, {
|
||||
saveTargetEditor,
|
||||
addTargetSegment,
|
||||
removeTargetSegment,
|
||||
syncSegmentsMappedMode,
|
||||
startTargetProcessing,
|
||||
stopTargetProcessing,
|
||||
startTargetOverlay,
|
||||
@@ -299,10 +307,20 @@ Object.assign(window, {
|
||||
colorCycleRemoveColor,
|
||||
compositeAddLayer,
|
||||
compositeRemoveLayer,
|
||||
mappedAddZone,
|
||||
mappedRemoveZone,
|
||||
onAudioVizChange,
|
||||
applyGradientPreset,
|
||||
cloneColorStrip,
|
||||
|
||||
// audio sources
|
||||
showAudioSourceModal,
|
||||
closeAudioSourceModal,
|
||||
saveAudioSource,
|
||||
editAudioSource,
|
||||
deleteAudioSource,
|
||||
onAudioSourceTypeChange,
|
||||
|
||||
// calibration
|
||||
showCalibration,
|
||||
closeCalibrationModal,
|
||||
|
||||
@@ -165,6 +165,10 @@ export const PATTERN_RECT_BORDERS = [
|
||||
'#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548',
|
||||
];
|
||||
|
||||
// Audio sources
|
||||
export let _cachedAudioSources = [];
|
||||
export function set_cachedAudioSources(v) { _cachedAudioSources = v; }
|
||||
|
||||
// Profiles
|
||||
export let _profilesCache = null;
|
||||
export function set_profilesCache(v) { _profilesCache = v; }
|
||||
|
||||
193
server/src/wled_controller/static/js/features/audio-sources.js
Normal file
193
server/src/wled_controller/static/js/features/audio-sources.js
Normal 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('');
|
||||
}
|
||||
@@ -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')}">×</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,
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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')}">×</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; };
|
||||
|
||||
Reference in New Issue
Block a user