Files
ledgrab/server/src/wled_controller/static/js/features/value-sources.js
T
alexei.dolgolyov e4c4301a7b Add dirty check to all remaining editor modals
Subclass Modal with snapshotValues() for: value source editor, audio
source editor, add device, profile editor, capture template, stream
editor, and PP template modals. Close/cancel now triggers discard
confirmation when form has unsaved changes. Document the convention
in CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:12:30 +03:00

372 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = `<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>
`;
} else if (src.source_type === 'adaptive_time') {
const pts = (src.schedule || []).length;
propsHtml = `
<span class="stream-card-prop">${pts} ${t('value_source.schedule.points')}</span>
<span class="stream-card-prop">${src.min_value ?? 0}${src.max_value ?? 1}</span>
`;
} 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 = `
<span class="stream-card-prop">${escapeHtml(psName)}</span>
<span class="stream-card-prop">${src.scene_behavior || 'complement'}</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('');
}
// ── Adaptive helpers ──────────────────────────────────────────
function _populatePictureSourceDropdown(selectedId) {
const select = document.getElementById('value-source-picture-source');
if (!select) return;
select.innerHTML = _cachedStreams.map(s =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).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 = `
<input type="time" class="schedule-time" value="${time || '12:00'}">
<input type="range" class="schedule-value" min="0" max="1" step="0.01" value="${value}"
oninput="this.nextElementSibling.textContent = this.value">
<span class="schedule-value-display">${value}</span>
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="this.parentElement.remove()">&#x2715;</button>
`;
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));
}
}