Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/value-sources.js
alexei.dolgolyov 304fa24389 Comprehensive WebUI review: 41 UX/feature/CSS improvements
Safety & Correctness:
- Add confirmation dialogs to Stop All, turnOffDevice
- i18n confirm dialog (title, yes, no buttons)
- Fix duplicate tutorial-overlay ID
- Define missing CSS variables (--radius, --text-primary, --hover-bg, --input-bg)
- Fix toast z-index conflict with confirm dialog (2500 → 3000)

UX Consistency:
- Add backdrop-close to test modals
- Add device clone feature (only entity without it)
- Add sync clocks to command palette
- Replace 20+ hardcoded accent colors with CSS vars/color-mix()
- Remove dead .badge duplicate from components.css
- Make calibration elements keyboard-accessible (div → button)
- Add aria-labels to color picker swatches
- Fix pattern canvas mobile horizontal scroll
- Fix graph editor mobile bottom clipping

Polish:
- Add empty-state messages to all CardSection instances
- Convert 21 px font-sizes to rem
- Add scroll-behavior: smooth with reduced-motion override
- Add @media print styles
- Add :focus-visible to 4 missing interactive elements
- Fix settings modal close label (Cancel → Close)
- Fix api-key submit button i18n

New Features:
- Command palette actions: start/stop targets, activate scenes, enable/disable
- Bulk start/stop API endpoints (POST /output-targets/bulk/start|stop)
- OS notification history viewer modal
- Scene "used by" automation reference count on cards
- Clock elapsed time display on Streams tab cards
- Device "last seen" relative timestamp on cards
- Audio device refresh button in edit modal
- Composite layer drag-to-reorder
- MQTT settings panel (broker config with JSON persistence)
- WebSocket log viewer with level filtering and ring buffer
- Runtime log-level adjustment (GET/PUT endpoints + settings UI)
- Animated value source waveform canvas preview
- Gradient custom preset save/delete (localStorage)
- API key read-only display in settings
- Backup metadata (file size, auto/manual badges)
- Server restart button with confirm + overlay
- Partial config export/import per entity type
- Progressive disclosure in target editor (Advanced section)

CSS Architecture:
- Define radius scale tokens (--radius-sm/md/lg/pill)
- Scope .cs-filter selectors to remove 7 !important overrides
- Consolidate duplicate toggle switch (filter-list → settings-toggle)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:46:38 +03:00

906 lines
41 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, _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, getAudioSourceIcon, getPictureSourceIcon,
ICON_CLONE, ICON_EDIT, ICON_TEST,
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK,
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH,
} from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
import { IconSelect, showTypePicker } from '../core/icon-select.js';
import { EntitySelect } from '../core/entity-palette.js';
import { loadPictureSources } from './streams.js';
export { getValueSourceIcon };
// ── EntitySelect instances for value source editor ──
let _vsAudioSourceEntitySelect = null;
let _vsPictureSourceEntitySelect = null;
let _vsTagsInput = null;
class ValueSourceModal extends Modal {
constructor() { super('value-source-modal'); }
onForceClose() {
if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; }
}
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()),
daylightSpeed: document.getElementById('value-source-daylight-speed').value,
daylightRealTime: document.getElementById('value-source-daylight-real-time').checked,
daylightLatitude: document.getElementById('value-source-daylight-latitude').value,
tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []),
};
}
}
const valueSourceModal = new ValueSourceModal();
/* ── Name auto-generation ────────────────────────────────────── */
let _vsNameManuallyEdited = false;
function _autoGenerateVSName() {
if (_vsNameManuallyEdited) return;
if (document.getElementById('value-source-id').value) return;
const type = document.getElementById('value-source-type').value;
const typeLabel = t(`value_source.type.${type}`);
let detail = '';
if (type === 'animated') {
const wf = document.getElementById('value-source-waveform').value;
detail = t(`value_source.waveform.${wf}`);
} else if (type === 'audio') {
const mode = document.getElementById('value-source-mode').value;
detail = t(`value_source.mode.${mode}`);
} else if (type === 'adaptive_scene') {
const sel = document.getElementById('value-source-picture-source');
const name = sel?.selectedOptions[0]?.textContent?.trim();
if (name) detail = name;
}
document.getElementById('value-source-name').value = detail ? `${typeLabel} · ${detail}` : typeLabel;
}
/* ── Icon-grid type selector ──────────────────────────────────── */
const VS_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight'];
function _buildVSTypeItems() {
return VS_TYPE_KEYS.map(key => ({
value: key,
icon: getValueSourceIcon(key),
label: t(`value_source.type.${key}`),
desc: t(`value_source.type.${key}.desc`),
}));
}
let _vsTypeIconSelect = null;
let _waveformIconSelect = null;
const _WAVEFORM_SVG = {
sine: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 12 Q15 -4 30 12 Q45 28 60 12"/></svg>',
triangle: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 12 L15 2 L45 22 L60 12"/></svg>',
square: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 20 L0 4 L30 4 L30 20 L60 20 L60 4"/></svg>',
sawtooth: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 20 L30 4 L30 20 L60 4"/></svg>',
};
function _ensureWaveformIconSelect() {
const sel = document.getElementById('value-source-waveform');
if (!sel) return;
const items = [
{ value: 'sine', icon: _WAVEFORM_SVG.sine, label: t('value_source.waveform.sine') },
{ value: 'triangle', icon: _WAVEFORM_SVG.triangle, label: t('value_source.waveform.triangle') },
{ value: 'square', icon: _WAVEFORM_SVG.square, label: t('value_source.waveform.square') },
{ value: 'sawtooth', icon: _WAVEFORM_SVG.sawtooth, label: t('value_source.waveform.sawtooth') },
];
if (_waveformIconSelect) { _waveformIconSelect.updateItems(items); return; }
_waveformIconSelect = new IconSelect({ target: sel, items, columns: 4 });
}
/* ── Waveform canvas preview ──────────────────────────────────── */
/**
* Draw a waveform preview on the canvas element #value-source-waveform-preview.
* Shows one full cycle of the selected waveform shape.
*/
function _drawWaveformPreview(waveformType) {
const canvas = document.getElementById('value-source-waveform-preview');
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.offsetWidth || 200;
const cssH = 60;
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
canvas.style.height = cssH + 'px';
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
const W = cssW;
const H = cssH;
const padX = 8;
const padY = 8;
const drawW = W - padX * 2;
const drawH = H - padY * 2;
const midY = padY + drawH / 2;
// Draw zero line
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(padX, midY);
ctx.lineTo(padX + drawW, midY);
ctx.stroke();
ctx.setLineDash([]);
// Draw waveform
const N = 120;
ctx.beginPath();
for (let i = 0; i <= N; i++) {
const t = i / N; // 0..1 over one cycle
let v; // -1..1
switch (waveformType) {
case 'triangle':
v = t < 0.5 ? (4 * t - 1) : (3 - 4 * t);
break;
case 'square':
v = t < 0.5 ? 1 : -1;
break;
case 'sawtooth':
v = 2 * t - 1;
break;
case 'sine':
default:
v = Math.sin(2 * Math.PI * t);
break;
}
const x = padX + t * drawW;
const y = midY - v * (drawH / 2);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
// Glow effect: draw thick translucent line first
ctx.strokeStyle = 'rgba(99,179,237,0.25)';
ctx.lineWidth = 4;
ctx.stroke();
// Crisp line on top
ctx.strokeStyle = '#63b3ed';
ctx.lineWidth = 1.5;
ctx.stroke();
}
export function updateWaveformPreview() {
const wf = document.getElementById('value-source-waveform')?.value || 'sine';
_drawWaveformPreview(wf);
}
/* ── Audio mode icon-grid selector ────────────────────────────── */
const _AUDIO_MODE_SVG = {
rms: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="8" width="12" height="12" rx="2" fill="currentColor" opacity="0.3"/><rect x="24" y="4" width="12" height="16" rx="2" fill="currentColor" opacity="0.5"/><rect x="44" y="6" width="12" height="14" rx="2" fill="currentColor" opacity="0.4"/></svg>',
peak: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 20 L10 14 L18 16 L26 4 L34 12 L42 18 L50 10 L58 20"/><circle cx="26" cy="4" r="2" fill="currentColor"/></svg>',
beat: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 12 L12 12 L16 2 L22 22 L28 6 L32 12 L60 12"/></svg>',
};
let _audioModeIconSelect = null;
function _ensureAudioModeIconSelect() {
const sel = document.getElementById('value-source-mode');
if (!sel) return;
const items = [
{ value: 'rms', icon: _AUDIO_MODE_SVG.rms, label: t('value_source.mode.rms'), desc: t('value_source.mode.rms.desc') },
{ value: 'peak', icon: _AUDIO_MODE_SVG.peak, label: t('value_source.mode.peak'), desc: t('value_source.mode.peak.desc') },
{ value: 'beat', icon: _AUDIO_MODE_SVG.beat, label: t('value_source.mode.beat'), desc: t('value_source.mode.beat.desc') },
];
if (_audioModeIconSelect) { _audioModeIconSelect.updateItems(items); return; }
_audioModeIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
function _ensureVSTypeIconSelect() {
const sel = document.getElementById('value-source-type');
if (!sel) return;
if (_vsTypeIconSelect) { _vsTypeIconSelect.updateItems(_buildVSTypeItems()); return; }
_vsTypeIconSelect = new IconSelect({ target: sel, items: _buildVSTypeItems(), columns: 2 });
}
// ── Modal ─────────────────────────────────────────────────────
export async function showValueSourceModal(editData, presetType = null) {
// When creating new: show type picker first, then re-enter with presetType
if (!editData && !presetType) {
showTypePicker({
title: t('value_source.select_type'),
items: _buildVSTypeItems(),
onPick: (type) => showValueSourceModal(null, type),
});
return;
}
const hasId = editData?.id;
const isEdit = !!hasId;
const sourceType = editData?.source_type || presetType || 'static';
const titleIcon = getValueSourceIcon(sourceType);
const titleKey = isEdit ? 'value_source.edit' : 'value_source.add';
const typeName = t(`value_source.type.${sourceType}`);
document.getElementById('value-source-modal-title').innerHTML = isEdit
? `${titleIcon} ${t(titleKey)}`
: `${titleIcon} ${t(titleKey)}: ${typeName}`;
document.getElementById('value-source-id').value = isEdit ? editData.id : '';
document.getElementById('value-source-error').style.display = 'none';
_vsNameManuallyEdited = !!(isEdit || editData);
document.getElementById('value-source-name').oninput = () => { _vsNameManuallyEdited = true; };
_ensureVSTypeIconSelect();
const typeSelect = document.getElementById('value-source-type');
// Type is chosen before the modal opens — always hide selector
document.getElementById('value-source-type-group').style.display = 'none';
if (editData) {
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';
if (_waveformIconSelect) _waveformIconSelect.setValue(editData.waveform || 'sine');
_drawWaveformPreview(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';
if (_audioModeIconSelect) _audioModeIconSelect.setValue(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 if (editData.source_type === 'daylight') {
_setSlider('value-source-daylight-speed', editData.speed ?? 1.0);
document.getElementById('value-source-daylight-real-time').checked = !!editData.use_real_time;
_setSlider('value-source-daylight-latitude', editData.latitude ?? 50);
_syncDaylightVSSpeedVisibility();
_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 = presetType || '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';
_drawWaveformPreview('sine');
_populateAudioSourceDropdown('');
document.getElementById('value-source-mode').value = 'rms';
if (_audioModeIconSelect) _audioModeIconSelect.setValue('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);
// Daylight defaults
_setSlider('value-source-daylight-speed', 1.0);
document.getElementById('value-source-daylight-real-time').checked = false;
_setSlider('value-source-daylight-latitude', 50);
_syncDaylightVSSpeedVisibility();
_autoGenerateVSName();
}
// Wire up auto-name triggers
document.getElementById('value-source-waveform').onchange = () => { _autoGenerateVSName(); _drawWaveformPreview(document.getElementById('value-source-waveform').value); };
document.getElementById('value-source-mode').onchange = () => _autoGenerateVSName();
document.getElementById('value-source-picture-source').onchange = () => _autoGenerateVSName();
// Tags
if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; }
_vsTagsInput = new TagInput(document.getElementById('value-source-tags-container'), { placeholder: t('tags.placeholder') });
_vsTagsInput.setValue(editData ? (editData.tags || []) : []);
valueSourceModal.open();
valueSourceModal.snapshot();
}
export async function closeValueSourceModal() {
await valueSourceModal.close();
}
export function onValueSourceTypeChange() {
const type = document.getElementById('value-source-type').value;
if (_vsTypeIconSelect) _vsTypeIconSelect.setValue(type);
document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : 'none';
document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none';
if (type === 'animated') { _ensureWaveformIconSelect(); _drawWaveformPreview(document.getElementById('value-source-waveform').value); }
document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none';
if (type === 'audio') _ensureAudioModeIconSelect();
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-daylight-section').style.display = type === 'daylight' ? '' : 'none';
document.getElementById('value-source-adaptive-range-section').style.display =
(type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : '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('');
}
_autoGenerateVSName();
}
// ── Daylight helpers ──────────────────────────────────────────
export function onDaylightVSRealTimeChange() {
_syncDaylightVSSpeedVisibility();
}
function _syncDaylightVSSpeedVisibility() {
const rt = document.getElementById('value-source-daylight-real-time').checked;
document.getElementById('value-source-daylight-speed-group').style.display = rt ? 'none' : '';
}
// ── 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, tags: _vsTagsInput ? _vsTagsInput.getValue() : [] };
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);
} else if (sourceType === 'daylight') {
payload.speed = parseFloat(document.getElementById('value-source-daylight-speed').value);
payload.use_real_time = document.getElementById('value-source-daylight-real-time').checked;
payload.latitude = parseFloat(document.getElementById('value-source-daylight-latitude').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(t('value_source.error.load'));
const data = await resp.json();
await showValueSourceModal(data);
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Clone ─────────────────────────────────────────────────────
export async function cloneValueSource(sourceId) {
try {
const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
if (!resp.ok) throw new Error(t('value_source.error.load'));
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 = `<span class="stream-card-prop">${ICON_LED_PREVIEW} ${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">${ICON_ACTIVITY} ${escapeHtml(waveLabel)}</span>
<span class="stream-card-prop">${ICON_TIMER} ${src.speed ?? 10} cpm</span>
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${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 audioSection = audioSrc ? (audioSrc.source_type === 'mono' ? 'audio-mono' : 'audio-multi') : 'audio-multi';
const modeLabel = src.mode || 'rms';
const audioBadge = audioSrc
? `<span class="stream-card-prop stream-card-link" title="${escapeHtml(t('value_source.audio_source'))}" onclick="event.stopPropagation(); navigateToCard('streams','audio','${audioSection}','data-id','${src.audio_source_id}')">${ICON_MUSIC} ${escapeHtml(audioName)}</span>`
: `<span class="stream-card-prop" title="${escapeHtml(t('value_source.audio_source'))}">${ICON_MUSIC} ${escapeHtml(audioName)}</span>`;
propsHtml = `
${audioBadge}
<span class="stream-card-prop">${ICON_TRENDING_UP} ${modeLabel.toUpperCase()}</span>
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span>
`;
} else if (src.source_type === 'adaptive_time') {
const pts = (src.schedule || []).length;
propsHtml = `
<span class="stream-card-prop">${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}</span>
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}${src.max_value ?? 1}</span>
`;
} else if (src.source_type === 'daylight') {
if (src.use_real_time) {
propsHtml = `<span class="stream-card-prop">${ICON_CLOCK} ${t('value_source.daylight.real_time')}</span>`;
} else {
propsHtml = `<span class="stream-card-prop">${ICON_TIMER} ${t('value_source.daylight.speed_label')} ${src.speed ?? 1.0}x</span>`;
}
propsHtml += `<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}\u2013${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 || '-');
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
? `<span class="stream-card-prop stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','${psSubTab}','${psSection}','data-stream-id','${src.picture_source_id}')" title="${escapeHtml(t('value_source.picture_source'))}">${ICON_MONITOR} ${escapeHtml(psName)}</span>`
: `<span class="stream-card-prop">${ICON_MONITOR} ${escapeHtml(psName)}</span>`;
propsHtml = `
${psBadge}
<span class="stream-card-prop">${ICON_REFRESH} ${src.scene_behavior || 'complement'}</span>
`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: src.id,
removeOnclick: `deleteValueSource('${src.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
</div>
<div class="stream-card-props">${propsHtml}</div>
${renderTagChips(src.tags)}
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="testValueSource('${src.id}')" title="${t('value_source.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneValueSource('${src.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editValueSource('${src.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── 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 `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}${badge}</option>`;
}).join('');
if (_vsAudioSourceEntitySelect) _vsAudioSourceEntitySelect.destroy();
if (_cachedAudioSources.length > 0) {
_vsAudioSourceEntitySelect = new EntitySelect({
target: select,
getItems: () => _cachedAudioSources.map(s => ({
value: s.id,
label: s.name,
icon: getAudioSourceIcon(s.source_type),
desc: s.source_type,
})),
placeholder: t('palette.search'),
});
}
}
// ── 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('');
if (_vsPictureSourceEntitySelect) _vsPictureSourceEntitySelect.destroy();
if (_cachedStreams.length > 0) {
_vsPictureSourceEntitySelect = new EntitySelect({
target: select,
getItems: () => _cachedStreams.map(s => ({
value: s.id,
label: s.name,
icon: getPictureSourceIcon(s.stream_type),
})),
placeholder: t('palette.search'),
});
}
}
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));
}
}