/** * 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, apiKey } from '../core/state.js'; import { API_BASE, 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 { getValueSourceIcon, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, } from '../core/icons.js'; import { loadPictureSources } from './streams.js'; export { getValueSourceIcon }; 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, autoGain: document.getElementById('value-source-auto-gain').checked, 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'; const titleIcon = isEdit ? getValueSourceIcon(editData.source_type) : getValueSourceIcon('static'); document.getElementById('value-source-modal-title').innerHTML = `${titleIcon} ${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'); document.getElementById('value-source-type-group').style.display = isEdit ? 'none' : ''; 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'; document.getElementById('value-source-auto-gain').checked = !!editData.auto_gain; _setSlider('value-source-sensitivity', editData.sensitivity ?? 1.0); _setSlider('value-source-smoothing', editData.smoothing ?? 0.3); _setSlider('value-source-audio-min-value', editData.min_value ?? 0); _setSlider('value-source-audio-max-value', editData.max_value ?? 1); } 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'; document.getElementById('value-source-auto-gain').checked = false; _setSlider('value-source-sensitivity', 1.0); _setSlider('value-source-smoothing', 0.3); _setSlider('value-source-audio-min-value', 0); _setSlider('value-source-audio-max-value', 1); // 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.auto_gain = document.getElementById('value-source-auto-gain').checked; payload.sensitivity = parseFloat(document.getElementById('value-source-sensitivity').value); payload.smoothing = parseFloat(document.getElementById('value-source-smoothing').value); payload.min_value = parseFloat(document.getElementById('value-source-audio-min-value').value); payload.max_value = parseFloat(document.getElementById('value-source-audio-max-value').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'); } } // ── Clone ───────────────────────────────────────────────────── export async function cloneValueSource(sourceId) { try { const resp = await fetchWithAuth(`/value-sources/${sourceId}`); if (!resp.ok) throw new Error('fetch failed'); const data = await resp.json(); delete data.id; data.name = data.name + ' (copy)'; await showValueSourceModal(data); } catch (e) { if (e.isAuth) return; 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'); } } // ── Value Source Test (real-time output chart) ──────────────── const VS_HISTORY_SIZE = 200; let _testVsWs = null; let _testVsAnimFrame = null; let _testVsLatest = null; let _testVsHistory = []; let _testVsMinObserved = Infinity; let _testVsMaxObserved = -Infinity; const testVsModal = new Modal('test-value-source-modal', { backdrop: true, lock: true }); export function testValueSource(sourceId) { const statusEl = document.getElementById('vs-test-status'); if (statusEl) { statusEl.textContent = t('value_source.test.connecting'); statusEl.style.display = ''; } // Reset state _testVsLatest = null; _testVsHistory = []; _testVsMinObserved = Infinity; _testVsMaxObserved = -Infinity; document.getElementById('vs-test-current').textContent = '---'; document.getElementById('vs-test-min').textContent = '---'; document.getElementById('vs-test-max').textContent = '---'; testVsModal.open(); // Size canvas to container const canvas = document.getElementById('vs-test-canvas'); _sizeVsCanvas(canvas); // Connect WebSocket const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}${API_BASE}/value-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}`; try { _testVsWs = new WebSocket(wsUrl); _testVsWs.onopen = () => { if (statusEl) statusEl.style.display = 'none'; }; _testVsWs.onmessage = (event) => { try { const data = JSON.parse(event.data); _testVsLatest = data.value; _testVsHistory.push(data.value); if (_testVsHistory.length > VS_HISTORY_SIZE) { _testVsHistory.shift(); } if (data.value < _testVsMinObserved) _testVsMinObserved = data.value; if (data.value > _testVsMaxObserved) _testVsMaxObserved = data.value; } catch {} }; _testVsWs.onclose = () => { _testVsWs = null; }; _testVsWs.onerror = () => { showToast(t('value_source.test.error'), 'error'); _cleanupVsTest(); }; } catch { showToast(t('value_source.test.error'), 'error'); _cleanupVsTest(); return; } // Start render loop _testVsAnimFrame = requestAnimationFrame(_renderVsTestLoop); } export function closeTestValueSourceModal() { _cleanupVsTest(); testVsModal.forceClose(); } function _cleanupVsTest() { if (_testVsAnimFrame) { cancelAnimationFrame(_testVsAnimFrame); _testVsAnimFrame = null; } if (_testVsWs) { _testVsWs.onclose = null; _testVsWs.close(); _testVsWs = null; } _testVsLatest = null; } function _sizeVsCanvas(canvas) { const rect = canvas.parentElement.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvas.width = rect.width * dpr; canvas.height = 200 * dpr; canvas.style.height = '200px'; canvas.getContext('2d').scale(dpr, dpr); } function _renderVsTestLoop() { _renderVsChart(); if (testVsModal.isOpen) { _testVsAnimFrame = requestAnimationFrame(_renderVsTestLoop); } } function _renderVsChart() { const canvas = document.getElementById('vs-test-canvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const w = canvas.width / dpr; const h = canvas.height / dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); // Draw horizontal guide lines at 0.0, 0.5, 1.0 ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; ctx.setLineDash([4, 4]); ctx.lineWidth = 1; for (const frac of [0, 0.5, 1.0]) { const y = h - frac * h; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } ctx.setLineDash([]); // Draw Y-axis labels ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; ctx.font = '10px monospace'; ctx.textAlign = 'left'; ctx.fillText('1.0', 4, 12); ctx.fillText('0.5', 4, h / 2 - 2); ctx.fillText('0.0', 4, h - 4); const history = _testVsHistory; if (history.length < 2) return; // Draw filled area under the line ctx.beginPath(); const stepX = w / (VS_HISTORY_SIZE - 1); const startOffset = VS_HISTORY_SIZE - history.length; ctx.moveTo(startOffset * stepX, h); for (let i = 0; i < history.length; i++) { const x = (startOffset + i) * stepX; const y = h - history[i] * h; ctx.lineTo(x, y); } ctx.lineTo((startOffset + history.length - 1) * stepX, h); ctx.closePath(); ctx.fillStyle = 'rgba(76, 175, 80, 0.15)'; ctx.fill(); // Draw the line ctx.beginPath(); for (let i = 0; i < history.length; i++) { const x = (startOffset + i) * stepX; const y = h - history[i] * h; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.strokeStyle = '#4caf50'; ctx.lineWidth = 2; ctx.stroke(); // Update stats if (_testVsLatest !== null) { document.getElementById('vs-test-current').textContent = (_testVsLatest * 100).toFixed(1) + '%'; } if (_testVsMinObserved !== Infinity) { document.getElementById('vs-test-min').textContent = (_testVsMinObserved * 100).toFixed(1) + '%'; } if (_testVsMaxObserved !== -Infinity) { document.getElementById('vs-test-max').textContent = (_testVsMaxObserved * 100).toFixed(1) + '%'; } } // ── Card rendering (used by streams.js) ─────────────────────── export function createValueSourceCard(src) { const icon = getValueSourceIcon(src.source_type); let propsHtml = ''; if (src.source_type === 'static') { propsHtml = `${ICON_LED_PREVIEW} ${t('value_source.type.static')}: ${src.value ?? 1.0}`; } else if (src.source_type === 'animated') { const waveLabel = src.waveform || 'sine'; propsHtml = ` ${ICON_ACTIVITY} ${escapeHtml(waveLabel)} ${ICON_TIMER} ${src.speed ?? 10} cpm ${ICON_MOVE_VERTICAL} ${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 audioSection = audioSrc ? (audioSrc.source_type === 'mono' ? 'audio-mono' : 'audio-multi') : 'audio-multi'; const modeLabel = src.mode || 'rms'; const audioBadge = audioSrc ? `${ICON_MUSIC} ${escapeHtml(audioName)}` : `${ICON_MUSIC} ${escapeHtml(audioName)}`; propsHtml = ` ${audioBadge} ${ICON_TRENDING_UP} ${modeLabel.toUpperCase()} ${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}–${src.max_value ?? 1} `; } else if (src.source_type === 'adaptive_time') { const pts = (src.schedule || []).length; propsHtml = ` ${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')} ${ICON_MOVE_VERTICAL} ${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 || '-'); let psSubTab = 'raw', psSection = 'raw-streams'; if (ps) { if (ps.stream_type === 'static_image') { psSubTab = 'static_image'; psSection = 'static-streams'; } else if (ps.stream_type === 'processed') { psSubTab = 'processed'; psSection = 'proc-streams'; } } const psBadge = ps ? `${ICON_MONITOR} ${escapeHtml(psName)}` : `${ICON_MONITOR} ${escapeHtml(psName)}`; propsHtml = ` ${psBadge} ${ICON_REFRESH} ${src.scene_behavior || 'complement'} `; } return `
${icon} ${escapeHtml(src.name)}
${propsHtml}
${src.description ? `
${escapeHtml(src.description)}
` : ''}
`; } // ── 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; select.innerHTML = _cachedAudioSources.map(s => { const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]'; return ``; }).join(''); } // ── Adaptive helpers ────────────────────────────────────────── function _populatePictureSourceDropdown(selectedId) { const select = document.getElementById('value-source-picture-source'); if (!select) return; select.innerHTML = _cachedStreams.map(s => `` ).join(''); } export function addSchedulePoint(time = '', value = 1.0) { const list = document.getElementById('value-source-schedule-list'); if (!list) return; const row = document.createElement('div'); row.className = 'schedule-row'; row.innerHTML = ` ${value} `; list.appendChild(row); } function _getScheduleFromUI() { const rows = document.querySelectorAll('#value-source-schedule-list .schedule-row'); const schedule = []; rows.forEach(row => { const time = row.querySelector('.schedule-time').value; const value = parseFloat(row.querySelector('.schedule-value').value); if (time) schedule.push({ time, value }); }); return schedule; } function _populateScheduleUI(schedule) { const list = document.getElementById('value-source-schedule-list'); if (!list) return; list.innerHTML = ''; if (!schedule || schedule.length === 0) { // Default: morning bright, night dim addSchedulePoint('08:00', 1.0); addSchedulePoint('22:00', 0.3); } else { schedule.forEach(p => addSchedulePoint(p.time, p.value)); } }