Add value sources for dynamic brightness control on LED targets

Introduces a new Value Source entity that produces a scalar float (0.0-1.0)
for dynamic brightness modulation. Three subtypes: Static (constant),
Animated (sine/triangle/square/sawtooth waveform), and Audio-reactive
(RMS/peak/beat from mono audio source). Value sources can be optionally
attached to LED targets to control brightness each frame.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 12:19:40 +03:00
parent 27720e51aa
commit ef474fe275
26 changed files with 1704 additions and 14 deletions

View File

@@ -0,0 +1,234 @@
/**
* Value Sources — CRUD for scalar value sources (static, animated, audio).
*
* Value sources produce a float 0.0-1.0 used for dynamic brightness control
* on LED targets. Three subtypes: static (constant), animated (waveform),
* audio (audio-reactive).
*
* Card rendering is handled by streams.js (Value tab).
* This module manages the editor modal and API operations.
*/
import { _cachedValueSources, set_cachedValueSources, _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 valueSourceModal = new Modal('value-source-modal');
// ── Modal ─────────────────────────────────────────────────────
export async function showValueSourceModal(editData) {
const isEdit = !!editData;
const titleKey = isEdit ? 'value_source.edit' : 'value_source.add';
document.getElementById('value-source-modal-title').textContent = t(titleKey);
document.getElementById('value-source-id').value = isEdit ? editData.id : '';
document.getElementById('value-source-error').style.display = 'none';
const typeSelect = document.getElementById('value-source-type');
typeSelect.disabled = isEdit;
if (isEdit) {
document.getElementById('value-source-name').value = editData.name || '';
document.getElementById('value-source-description').value = editData.description || '';
typeSelect.value = editData.source_type || 'static';
onValueSourceTypeChange();
if (editData.source_type === 'static') {
_setSlider('value-source-value', editData.value ?? 1.0);
} else if (editData.source_type === 'animated') {
document.getElementById('value-source-waveform').value = editData.waveform || 'sine';
_setSlider('value-source-speed', editData.speed ?? 10);
_setSlider('value-source-min-value', editData.min_value ?? 0);
_setSlider('value-source-max-value', editData.max_value ?? 1);
} else if (editData.source_type === 'audio') {
_populateAudioSourceDropdown(editData.audio_source_id || '');
document.getElementById('value-source-mode').value = editData.mode || 'rms';
_setSlider('value-source-sensitivity', editData.sensitivity ?? 1.0);
_setSlider('value-source-smoothing', editData.smoothing ?? 0.3);
}
} else {
document.getElementById('value-source-name').value = '';
document.getElementById('value-source-description').value = '';
typeSelect.value = 'static';
onValueSourceTypeChange();
_setSlider('value-source-value', 1.0);
_setSlider('value-source-speed', 10);
_setSlider('value-source-min-value', 0);
_setSlider('value-source-max-value', 1);
document.getElementById('value-source-waveform').value = 'sine';
_populateAudioSourceDropdown('');
document.getElementById('value-source-mode').value = 'rms';
_setSlider('value-source-sensitivity', 1.0);
_setSlider('value-source-smoothing', 0.3);
}
valueSourceModal.open();
}
export function closeValueSourceModal() {
valueSourceModal.forceClose();
}
export function onValueSourceTypeChange() {
const type = document.getElementById('value-source-type').value;
document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : 'none';
document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none';
document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none';
// Populate audio dropdown when switching to audio type
if (type === 'audio') {
const select = document.getElementById('value-source-audio-source');
if (select && select.options.length === 0) {
_populateAudioSourceDropdown('');
}
}
}
// ── Save ──────────────────────────────────────────────────────
export async function saveValueSource() {
const id = document.getElementById('value-source-id').value;
const name = document.getElementById('value-source-name').value.trim();
const sourceType = document.getElementById('value-source-type').value;
const description = document.getElementById('value-source-description').value.trim() || null;
const errorEl = document.getElementById('value-source-error');
if (!name) {
errorEl.textContent = t('value_source.error.name_required');
errorEl.style.display = '';
return;
}
const payload = { name, source_type: sourceType, description };
if (sourceType === 'static') {
payload.value = parseFloat(document.getElementById('value-source-value').value);
} else if (sourceType === 'animated') {
payload.waveform = document.getElementById('value-source-waveform').value;
payload.speed = parseFloat(document.getElementById('value-source-speed').value);
payload.min_value = parseFloat(document.getElementById('value-source-min-value').value);
payload.max_value = parseFloat(document.getElementById('value-source-max-value').value);
} else if (sourceType === 'audio') {
payload.audio_source_id = document.getElementById('value-source-audio-source').value;
payload.mode = document.getElementById('value-source-mode').value;
payload.sensitivity = parseFloat(document.getElementById('value-source-sensitivity').value);
payload.smoothing = parseFloat(document.getElementById('value-source-smoothing').value);
}
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/value-sources/${id}` : '/value-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 ? 'value_source.updated' : 'value_source.created'), 'success');
valueSourceModal.forceClose();
await loadPictureSources();
} catch (e) {
errorEl.textContent = e.message;
errorEl.style.display = '';
}
}
// ── Edit ──────────────────────────────────────────────────────
export async function editValueSource(sourceId) {
try {
const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
await showValueSourceModal(data);
} catch (e) {
showToast(e.message, 'error');
}
}
// ── Delete ────────────────────────────────────────────────────
export async function deleteValueSource(sourceId) {
const confirmed = await showConfirm(t('value_source.delete.confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/value-sources/${sourceId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('value_source.deleted'), 'success');
await loadPictureSources();
} catch (e) {
showToast(e.message, 'error');
}
}
// ── Card rendering (used by streams.js) ───────────────────────
export function createValueSourceCard(src) {
const typeIcons = { static: '📊', animated: '🔄', audio: '🎵' };
const icon = typeIcons[src.source_type] || '🎚️';
let propsHtml = '';
if (src.source_type === 'static') {
propsHtml = `<span class="stream-card-prop">${t('value_source.type.static')}: ${src.value ?? 1.0}</span>`;
} else if (src.source_type === 'animated') {
const waveLabel = src.waveform || 'sine';
propsHtml = `
<span class="stream-card-prop">${escapeHtml(waveLabel)}</span>
<span class="stream-card-prop">${src.speed ?? 10} cpm</span>
<span class="stream-card-prop">${src.min_value ?? 0}${src.max_value ?? 1}</span>
`;
} else if (src.source_type === 'audio') {
const audioSrc = _cachedAudioSources.find(a => a.id === src.audio_source_id);
const audioName = audioSrc ? audioSrc.name : (src.audio_source_id || '-');
const modeLabel = src.mode || 'rms';
propsHtml = `
<span class="stream-card-prop" title="${escapeHtml(t('value_source.audio_source'))}">${escapeHtml(audioName)}</span>
<span class="stream-card-prop">${modeLabel.toUpperCase()}</span>
`;
}
return `
<div class="template-card" data-id="${src.id}">
<button class="card-remove-btn" onclick="deleteValueSource('${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="editValueSource('${src.id}')" title="${t('common.edit')}">✏️</button>
</div>
</div>
`;
}
// ── Helpers ───────────────────────────────────────────────────
function _setSlider(id, value) {
const slider = document.getElementById(id);
if (slider) {
slider.value = value;
const display = document.getElementById(id + '-display');
if (display) display.textContent = value;
}
}
function _populateAudioSourceDropdown(selectedId) {
const select = document.getElementById('value-source-audio-source');
if (!select) return;
const mono = _cachedAudioSources.filter(s => s.source_type === 'mono');
select.innerHTML = mono.map(s =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
}