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

@@ -2,8 +2,8 @@ header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0 10px;
margin-bottom: 10px;
padding: 8px 0 6px;
margin-bottom: 6px;
position: relative;
z-index: 100;
}
@@ -11,11 +11,11 @@ header {
.header-title {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
}
h1 {
font-size: 2rem;
font-size: 1.25rem;
color: var(--primary-text-color);
}
@@ -28,7 +28,7 @@ h2 {
.server-info {
display: flex;
align-items: center;
gap: 15px;
gap: 8px;
}
.header-link {
@@ -57,7 +57,7 @@ h2 {
}
.status-badge {
font-size: 1.5rem;
font-size: 1rem;
animation: pulse 2s infinite;
}
@@ -156,12 +156,12 @@ h2 {
.theme-toggle {
background: var(--card-bg);
border: 1px solid var(--border-color);
padding: 6px 12px;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 1.2rem;
font-size: 1rem;
transition: transform 0.2s;
margin-left: 10px;
margin-left: 0;
}
.theme-toggle:hover {
@@ -170,14 +170,14 @@ h2 {
/* Footer */
.app-footer {
margin-top: 20px;
padding: 15px 0;
margin-top: 12px;
padding: 6px 0;
text-align: center;
}
.footer-content {
color: var(--text-secondary);
font-size: 0.9rem;
font-size: 0.75rem;
}
.footer-content p {
@@ -203,7 +203,7 @@ h2 {
@media (max-width: 768px) {
header {
flex-direction: column;
gap: 15px;
gap: 8px;
text-align: center;
}
}

View File

@@ -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,

View File

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

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

View File

@@ -249,6 +249,7 @@
"streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.",
"streams.group.raw": "Screen Capture",
"streams.group.processed": "Processed",
"streams.group.audio": "Audio",
"streams.section.streams": "\uD83D\uDCFA Sources",
"streams.add": "Add Source",
"streams.add.raw": "Add Screen Capture",
@@ -365,6 +366,7 @@
"targets.device.hint": "Select the LED device to send data to",
"targets.device.none": "-- Select a device --",
"targets.segments": "Segments:",
"targets.color_strip_source": "Color Strip Source:",
"targets.segments.hint": "Each segment maps a color strip source to a pixel range on the LED strip. Gaps between segments stay black. A single segment with Start=0, End=0 auto-fits to the full strip.",
"targets.segments.add": "+ Add Segment",
"targets.segment.start": "Start:",
@@ -652,6 +654,8 @@
"color_strip.type.effect.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.",
"color_strip.type.composite": "Composite",
"color_strip.type.composite.hint": "Stack multiple color strip sources as layers with blend modes and opacity.",
"color_strip.type.mapped": "Mapped",
"color_strip.type.mapped.hint": "Assign different color strip sources to different LED ranges (zones). Unlike composite which blends layers, mapped places sources side-by-side.",
"color_strip.type.audio": "Audio Reactive",
"color_strip.type.audio.hint": "LED colors driven by real-time audio input — system audio or microphone.",
"color_strip.composite.layers": "Layers:",
@@ -668,18 +672,22 @@
"color_strip.composite.error.min_layers": "At least 1 layer is required",
"color_strip.composite.error.no_source": "Each layer must have a source selected",
"color_strip.composite.layers_count": "layers",
"color_strip.mapped.zones": "Zones:",
"color_strip.mapped.zones.hint": "Each zone maps a color strip source to a specific LED range. Zones are placed side-by-side — gaps between zones stay black.",
"color_strip.mapped.add_zone": "+ Add Zone",
"color_strip.mapped.zone_source": "Source",
"color_strip.mapped.zone_start": "Start LED",
"color_strip.mapped.zone_end": "End LED",
"color_strip.mapped.zone_reverse": "Reverse",
"color_strip.mapped.zones_count": "zones",
"color_strip.mapped.error.no_source": "Each zone must have a source selected",
"color_strip.audio.visualization": "Visualization:",
"color_strip.audio.visualization.hint": "How audio data is rendered to LEDs.",
"color_strip.audio.viz.spectrum": "Spectrum Analyzer",
"color_strip.audio.viz.beat_pulse": "Beat Pulse",
"color_strip.audio.viz.vu_meter": "VU Meter",
"color_strip.audio.device": "Audio Device:",
"color_strip.audio.device.hint": "Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.",
"color_strip.audio.channel": "Channel:",
"color_strip.audio.channel.hint": "Select which audio channel to visualize. Use Left/Right for stereo setups.",
"color_strip.audio.channel.mono": "Mono (L+R mix)",
"color_strip.audio.channel.left": "Left",
"color_strip.audio.channel.right": "Right",
"color_strip.audio.source": "Audio Source:",
"color_strip.audio.source.hint": "Mono audio source that provides audio data for this visualization. Create and manage audio sources in the Sources tab.",
"color_strip.audio.sensitivity": "Sensitivity:",
"color_strip.audio.sensitivity.hint": "Gain multiplier for audio levels. Higher values make LEDs react to quieter sounds.",
"color_strip.audio.smoothing": "Smoothing:",
@@ -723,5 +731,39 @@
"color_strip.palette.rainbow": "Rainbow",
"color_strip.palette.aurora": "Aurora",
"color_strip.palette.sunset": "Sunset",
"color_strip.palette.ice": "Ice"
"color_strip.palette.ice": "Ice",
"audio_source.title": "Audio Sources",
"audio_source.group.multichannel": "Multichannel",
"audio_source.group.mono": "Mono",
"audio_source.add": "Add Audio Source",
"audio_source.add.multichannel": "Add Multichannel Source",
"audio_source.add.mono": "Add Mono Source",
"audio_source.edit": "Edit Audio Source",
"audio_source.edit.multichannel": "Edit Multichannel Source",
"audio_source.edit.mono": "Edit Mono Source",
"audio_source.name": "Name:",
"audio_source.name.placeholder": "System Audio",
"audio_source.name.hint": "A descriptive name for this audio source",
"audio_source.type": "Type:",
"audio_source.type.hint": "Multichannel captures all channels from a physical audio device. Mono extracts a single channel from a multichannel source.",
"audio_source.type.multichannel": "Multichannel",
"audio_source.type.mono": "Mono",
"audio_source.device": "Audio Device:",
"audio_source.device.hint": "Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.",
"audio_source.parent": "Parent Source:",
"audio_source.parent.hint": "Multichannel source to extract a channel from",
"audio_source.channel": "Channel:",
"audio_source.channel.hint": "Which audio channel to extract from the multichannel source",
"audio_source.channel.mono": "Mono (L+R mix)",
"audio_source.channel.left": "Left",
"audio_source.channel.right": "Right",
"audio_source.description": "Description (optional):",
"audio_source.description.placeholder": "Describe this audio source...",
"audio_source.description.hint": "Optional notes about this audio source",
"audio_source.created": "Audio source created",
"audio_source.updated": "Audio source updated",
"audio_source.deleted": "Audio source deleted",
"audio_source.delete.confirm": "Are you sure you want to delete this audio source?",
"audio_source.error.name_required": "Please enter a name"
}

View File

@@ -249,6 +249,7 @@
"streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.",
"streams.group.raw": "Захват Экрана",
"streams.group.processed": "Обработанные",
"streams.group.audio": "Аудио",
"streams.section.streams": "\uD83D\uDCFA Источники",
"streams.add": "Добавить Источник",
"streams.add.raw": "Добавить Захват Экрана",
@@ -365,6 +366,7 @@
"targets.device.hint": "Выберите LED устройство для передачи данных",
"targets.device.none": "-- Выберите устройство --",
"targets.segments": "Сегменты:",
"targets.color_strip_source": "Источник цветовой полосы:",
"targets.segments.hint": "Каждый сегмент отображает источник цветовой полосы на диапазон пикселей LED ленты. Промежутки между сегментами остаются чёрными. Один сегмент с Начало=0, Конец=0 авто-подгоняется под всю ленту.",
"targets.segments.add": "+ Добавить сегмент",
"targets.segment.start": "Начало:",
@@ -652,6 +654,8 @@
"color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.",
"color_strip.type.composite": "Композит",
"color_strip.type.composite.hint": "Наложение нескольких источников цветовой ленты как слоёв с режимами смешивания и прозрачностью.",
"color_strip.type.mapped": "Маппинг",
"color_strip.type.mapped.hint": "Назначает разные источники цветовой полосы на разные диапазоны LED (зоны). В отличие от композита, маппинг размещает источники рядом друг с другом.",
"color_strip.type.audio": "Аудиореактив",
"color_strip.type.audio.hint": "Цвета LED управляются аудиосигналом в реальном времени — системный звук или микрофон.",
"color_strip.composite.layers": "Слои:",
@@ -668,18 +672,22 @@
"color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой",
"color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник",
"color_strip.composite.layers_count": "слоёв",
"color_strip.mapped.zones": "Зоны:",
"color_strip.mapped.zones.hint": "Каждая зона привязывает источник цветовой полосы к определённому диапазону LED. Зоны размещаются рядом — промежутки остаются чёрными.",
"color_strip.mapped.add_zone": "+ Добавить зону",
"color_strip.mapped.zone_source": "Источник",
"color_strip.mapped.zone_start": "Начало LED",
"color_strip.mapped.zone_end": "Конец LED",
"color_strip.mapped.zone_reverse": "Реверс",
"color_strip.mapped.zones_count": "зон",
"color_strip.mapped.error.no_source": "Для каждой зоны должен быть выбран источник",
"color_strip.audio.visualization": "Визуализация:",
"color_strip.audio.visualization.hint": "Способ отображения аудиоданных на LED.",
"color_strip.audio.viz.spectrum": "Анализатор спектра",
"color_strip.audio.viz.beat_pulse": "Пульс бита",
"color_strip.audio.viz.vu_meter": "VU-метр",
"color_strip.audio.device": "Аудиоустройство:",
"color_strip.audio.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.",
"color_strip.audio.channel": "Канал:",
"color_strip.audio.channel.hint": "Какой аудиоканал визуализировать. Используйте Левый/Правый для стерео-режима.",
"color_strip.audio.channel.mono": "Моно (Л+П микс)",
"color_strip.audio.channel.left": "Левый",
"color_strip.audio.channel.right": "Правый",
"color_strip.audio.source": "Аудиоисточник:",
"color_strip.audio.source.hint": "Моно-аудиоисточник, предоставляющий аудиоданные для визуализации. Создавайте и управляйте аудиоисточниками на вкладке Источники.",
"color_strip.audio.sensitivity": "Чувствительность:",
"color_strip.audio.sensitivity.hint": "Множитель усиления аудиосигнала. Более высокие значения делают LED чувствительнее к тихим звукам.",
"color_strip.audio.smoothing": "Сглаживание:",
@@ -723,5 +731,39 @@
"color_strip.palette.rainbow": "Радуга",
"color_strip.palette.aurora": "Аврора",
"color_strip.palette.sunset": "Закат",
"color_strip.palette.ice": "Лёд"
"color_strip.palette.ice": "Лёд",
"audio_source.title": "Аудиоисточники",
"audio_source.group.multichannel": "Многоканальные",
"audio_source.group.mono": "Моно",
"audio_source.add": "Добавить аудиоисточник",
"audio_source.add.multichannel": "Добавить многоканальный",
"audio_source.add.mono": "Добавить моно",
"audio_source.edit": "Редактировать аудиоисточник",
"audio_source.edit.multichannel": "Редактировать многоканальный",
"audio_source.edit.mono": "Редактировать моно",
"audio_source.name": "Название:",
"audio_source.name.placeholder": "Системный звук",
"audio_source.name.hint": "Описательное имя для этого аудиоисточника",
"audio_source.type": "Тип:",
"audio_source.type.hint": "Многоканальный захватывает все каналы с аудиоустройства. Моно извлекает один канал из многоканального источника.",
"audio_source.type.multichannel": "Многоканальный",
"audio_source.type.mono": "Моно",
"audio_source.device": "Аудиоустройство:",
"audio_source.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.",
"audio_source.parent": "Родительский источник:",
"audio_source.parent.hint": "Многоканальный источник для извлечения канала",
"audio_source.channel": "Канал:",
"audio_source.channel.hint": "Какой аудиоканал извлечь из многоканального источника",
"audio_source.channel.mono": "Моно (Л+П микс)",
"audio_source.channel.left": "Левый",
"audio_source.channel.right": "Правый",
"audio_source.description": "Описание (необязательно):",
"audio_source.description.placeholder": "Опишите этот аудиоисточник...",
"audio_source.description.hint": "Необязательные заметки об этом аудиоисточнике",
"audio_source.created": "Аудиоисточник создан",
"audio_source.updated": "Аудиоисточник обновлён",
"audio_source.deleted": "Аудиоисточник удалён",
"audio_source.delete.confirm": "Удалить этот аудиоисточник?",
"audio_source.error.name_required": "Введите название"
}