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>
906 lines
41 KiB
JavaScript
906 lines
41 KiB
JavaScript
/**
|
||
* 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()">✕</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));
|
||
}
|
||
}
|