/** * Value Sources — CRUD for scalar value sources (static, animated, audio, adaptive_time, adaptive_scene). * * Value sources produce a float 0.0-1.0 used for dynamic brightness control * on LED targets. Five subtypes: static (constant), animated (waveform), * audio (audio-reactive), adaptive_time (time-of-day schedule), * adaptive_scene (scene brightness analysis). * * Card rendering is handled by streams.js (Value tab). * This module manages the editor modal and API operations. */ import { _cachedValueSources, set_cachedValueSources, _cachedAudioSources, _cachedStreams } 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'; class ValueSourceModal extends Modal { constructor() { super('value-source-modal'); } snapshotValues() { const type = document.getElementById('value-source-type').value; return { name: document.getElementById('value-source-name').value, description: document.getElementById('value-source-description').value, type, value: document.getElementById('value-source-value').value, waveform: document.getElementById('value-source-waveform').value, speed: document.getElementById('value-source-speed').value, minValue: document.getElementById('value-source-min-value').value, maxValue: document.getElementById('value-source-max-value').value, audioSource: document.getElementById('value-source-audio-source').value, mode: document.getElementById('value-source-mode').value, sensitivity: document.getElementById('value-source-sensitivity').value, smoothing: document.getElementById('value-source-smoothing').value, adaptiveMin: document.getElementById('value-source-adaptive-min-value').value, adaptiveMax: document.getElementById('value-source-adaptive-max-value').value, pictureSource: document.getElementById('value-source-picture-source').value, sceneBehavior: document.getElementById('value-source-scene-behavior').value, sceneSensitivity: document.getElementById('value-source-scene-sensitivity').value, sceneSmoothing: document.getElementById('value-source-scene-smoothing').value, schedule: JSON.stringify(_getScheduleFromUI()), }; } } const valueSourceModal = new ValueSourceModal(); // ── 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 if (editData.source_type === 'adaptive_time') { _populateScheduleUI(editData.schedule); _setSlider('value-source-adaptive-min-value', editData.min_value ?? 0); _setSlider('value-source-adaptive-max-value', editData.max_value ?? 1); } else if (editData.source_type === 'adaptive_scene') { _populatePictureSourceDropdown(editData.picture_source_id || ''); document.getElementById('value-source-scene-behavior').value = editData.scene_behavior || 'complement'; _setSlider('value-source-scene-sensitivity', editData.sensitivity ?? 1.0); _setSlider('value-source-scene-smoothing', editData.smoothing ?? 0.3); _setSlider('value-source-adaptive-min-value', editData.min_value ?? 0); _setSlider('value-source-adaptive-max-value', editData.max_value ?? 1); } } 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); // Adaptive defaults _populateScheduleUI([]); _populatePictureSourceDropdown(''); document.getElementById('value-source-scene-behavior').value = 'complement'; _setSlider('value-source-scene-sensitivity', 1.0); _setSlider('value-source-scene-smoothing', 0.3); _setSlider('value-source-adaptive-min-value', 0); _setSlider('value-source-adaptive-max-value', 1); } valueSourceModal.open(); valueSourceModal.snapshot(); } export async function closeValueSourceModal() { await valueSourceModal.close(); } 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'; document.getElementById('value-source-adaptive-time-section').style.display = type === 'adaptive_time' ? '' : 'none'; document.getElementById('value-source-adaptive-scene-section').style.display = type === 'adaptive_scene' ? '' : 'none'; document.getElementById('value-source-adaptive-range-section').style.display = (type === 'adaptive_time' || type === 'adaptive_scene') ? '' : '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(''); } } // Populate picture source dropdown when switching to scene type if (type === 'adaptive_scene') { _populatePictureSourceDropdown(''); } } // ── 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); } else if (sourceType === 'adaptive_time') { payload.schedule = _getScheduleFromUI(); if (payload.schedule.length < 2) { errorEl.textContent = t('value_source.error.schedule_min'); errorEl.style.display = ''; return; } payload.min_value = parseFloat(document.getElementById('value-source-adaptive-min-value').value); payload.max_value = parseFloat(document.getElementById('value-source-adaptive-max-value').value); } else if (sourceType === 'adaptive_scene') { payload.picture_source_id = document.getElementById('value-source-picture-source').value; payload.scene_behavior = document.getElementById('value-source-scene-behavior').value; payload.sensitivity = parseFloat(document.getElementById('value-source-scene-sensitivity').value); payload.smoothing = parseFloat(document.getElementById('value-source-scene-smoothing').value); payload.min_value = parseFloat(document.getElementById('value-source-adaptive-min-value').value); payload.max_value = parseFloat(document.getElementById('value-source-adaptive-max-value').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: '🎵', adaptive_time: '🕐', adaptive_scene: '🌤️' }; const icon = typeIcons[src.source_type] || '🎚️'; let propsHtml = ''; if (src.source_type === 'static') { propsHtml = `${t('value_source.type.static')}: ${src.value ?? 1.0}`; } else if (src.source_type === 'animated') { const waveLabel = src.waveform || 'sine'; propsHtml = ` ${escapeHtml(waveLabel)} ${src.speed ?? 10} cpm ${src.min_value ?? 0}–${src.max_value ?? 1} `; } 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 = ` ${escapeHtml(audioName)} ${modeLabel.toUpperCase()} `; } else if (src.source_type === 'adaptive_time') { const pts = (src.schedule || []).length; propsHtml = ` ${pts} ${t('value_source.schedule.points')} ${src.min_value ?? 0}–${src.max_value ?? 1} `; } else if (src.source_type === 'adaptive_scene') { const ps = _cachedStreams.find(s => s.id === src.picture_source_id); const psName = ps ? ps.name : (src.picture_source_id || '-'); propsHtml = ` ${escapeHtml(psName)} ${src.scene_behavior || 'complement'} `; } return `