- Add real-time value source test: WebSocket endpoint streams get_value() at ~20Hz, frontend renders scrolling time-series chart with min/max/current stats - Add auto-gain for audio value sources: rolling peak normalization with slow decay, sensitivity range increased to 0.1-20.0 - Always show brightness overlay on LED preview when brightness source is set - Refactor ValueStreamManager to shared ref-counted streams (value streams produce scalars, not LED-count-dependent, so sharing is correct) - Simplify acquire/release API: remove consumer_id parameter since streams are no longer consumer-dependent Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
591 lines
26 KiB
JavaScript
591 lines
26 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, set_cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey } from '../core/state.js';
|
||
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||
import { t } from '../core/i18n.js';
|
||
import { showToast, showConfirm } from '../core/ui.js';
|
||
import { Modal } from '../core/modal.js';
|
||
import { getValueSourceIcon, ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.js';
|
||
import { loadPictureSources } from './streams.js';
|
||
|
||
export { getValueSourceIcon };
|
||
|
||
class ValueSourceModal extends Modal {
|
||
constructor() { super('value-source-modal'); }
|
||
|
||
snapshotValues() {
|
||
const type = document.getElementById('value-source-type').value;
|
||
return {
|
||
name: document.getElementById('value-source-name').value,
|
||
description: document.getElementById('value-source-description').value,
|
||
type,
|
||
value: document.getElementById('value-source-value').value,
|
||
waveform: document.getElementById('value-source-waveform').value,
|
||
speed: document.getElementById('value-source-speed').value,
|
||
minValue: document.getElementById('value-source-min-value').value,
|
||
maxValue: document.getElementById('value-source-max-value').value,
|
||
audioSource: document.getElementById('value-source-audio-source').value,
|
||
mode: document.getElementById('value-source-mode').value,
|
||
sensitivity: document.getElementById('value-source-sensitivity').value,
|
||
smoothing: document.getElementById('value-source-smoothing').value,
|
||
autoGain: document.getElementById('value-source-auto-gain').checked,
|
||
adaptiveMin: document.getElementById('value-source-adaptive-min-value').value,
|
||
adaptiveMax: document.getElementById('value-source-adaptive-max-value').value,
|
||
pictureSource: document.getElementById('value-source-picture-source').value,
|
||
sceneBehavior: document.getElementById('value-source-scene-behavior').value,
|
||
sceneSensitivity: document.getElementById('value-source-scene-sensitivity').value,
|
||
sceneSmoothing: document.getElementById('value-source-scene-smoothing').value,
|
||
schedule: JSON.stringify(_getScheduleFromUI()),
|
||
};
|
||
}
|
||
}
|
||
|
||
const valueSourceModal = new ValueSourceModal();
|
||
|
||
// ── Modal ─────────────────────────────────────────────────────
|
||
|
||
export async function showValueSourceModal(editData) {
|
||
const isEdit = !!editData;
|
||
const titleKey = isEdit ? 'value_source.edit' : 'value_source.add';
|
||
|
||
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';
|
||
document.getElementById('value-source-auto-gain').checked = !!editData.auto_gain;
|
||
_setSlider('value-source-sensitivity', editData.sensitivity ?? 1.0);
|
||
_setSlider('value-source-smoothing', editData.smoothing ?? 0.3);
|
||
_setSlider('value-source-audio-min-value', editData.min_value ?? 0);
|
||
_setSlider('value-source-audio-max-value', editData.max_value ?? 1);
|
||
} else if (editData.source_type === 'adaptive_time') {
|
||
_populateScheduleUI(editData.schedule);
|
||
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
|
||
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
|
||
} else if (editData.source_type === 'adaptive_scene') {
|
||
_populatePictureSourceDropdown(editData.picture_source_id || '');
|
||
document.getElementById('value-source-scene-behavior').value = editData.scene_behavior || 'complement';
|
||
_setSlider('value-source-scene-sensitivity', editData.sensitivity ?? 1.0);
|
||
_setSlider('value-source-scene-smoothing', editData.smoothing ?? 0.3);
|
||
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
|
||
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
|
||
}
|
||
} else {
|
||
document.getElementById('value-source-name').value = '';
|
||
document.getElementById('value-source-description').value = '';
|
||
typeSelect.value = 'static';
|
||
onValueSourceTypeChange();
|
||
_setSlider('value-source-value', 1.0);
|
||
_setSlider('value-source-speed', 10);
|
||
_setSlider('value-source-min-value', 0);
|
||
_setSlider('value-source-max-value', 1);
|
||
document.getElementById('value-source-waveform').value = 'sine';
|
||
_populateAudioSourceDropdown('');
|
||
document.getElementById('value-source-mode').value = 'rms';
|
||
document.getElementById('value-source-auto-gain').checked = false;
|
||
_setSlider('value-source-sensitivity', 1.0);
|
||
_setSlider('value-source-smoothing', 0.3);
|
||
_setSlider('value-source-audio-min-value', 0);
|
||
_setSlider('value-source-audio-max-value', 1);
|
||
// Adaptive defaults
|
||
_populateScheduleUI([]);
|
||
_populatePictureSourceDropdown('');
|
||
document.getElementById('value-source-scene-behavior').value = 'complement';
|
||
_setSlider('value-source-scene-sensitivity', 1.0);
|
||
_setSlider('value-source-scene-smoothing', 0.3);
|
||
_setSlider('value-source-adaptive-min-value', 0);
|
||
_setSlider('value-source-adaptive-max-value', 1);
|
||
}
|
||
|
||
valueSourceModal.open();
|
||
valueSourceModal.snapshot();
|
||
}
|
||
|
||
export async function closeValueSourceModal() {
|
||
await valueSourceModal.close();
|
||
}
|
||
|
||
export function onValueSourceTypeChange() {
|
||
const type = document.getElementById('value-source-type').value;
|
||
document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : 'none';
|
||
document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none';
|
||
document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none';
|
||
document.getElementById('value-source-adaptive-time-section').style.display = type === 'adaptive_time' ? '' : 'none';
|
||
document.getElementById('value-source-adaptive-scene-section').style.display = type === 'adaptive_scene' ? '' : 'none';
|
||
document.getElementById('value-source-adaptive-range-section').style.display =
|
||
(type === 'adaptive_time' || type === 'adaptive_scene') ? '' : 'none';
|
||
|
||
// Populate audio dropdown when switching to audio type
|
||
if (type === 'audio') {
|
||
const select = document.getElementById('value-source-audio-source');
|
||
if (select && select.options.length === 0) {
|
||
_populateAudioSourceDropdown('');
|
||
}
|
||
}
|
||
|
||
// Populate picture source dropdown when switching to scene type
|
||
if (type === 'adaptive_scene') {
|
||
_populatePictureSourceDropdown('');
|
||
}
|
||
}
|
||
|
||
// ── Save ──────────────────────────────────────────────────────
|
||
|
||
export async function saveValueSource() {
|
||
const id = document.getElementById('value-source-id').value;
|
||
const name = document.getElementById('value-source-name').value.trim();
|
||
const sourceType = document.getElementById('value-source-type').value;
|
||
const description = document.getElementById('value-source-description').value.trim() || null;
|
||
const errorEl = document.getElementById('value-source-error');
|
||
|
||
if (!name) {
|
||
errorEl.textContent = t('value_source.error.name_required');
|
||
errorEl.style.display = '';
|
||
return;
|
||
}
|
||
|
||
const payload = { name, source_type: sourceType, description };
|
||
|
||
if (sourceType === 'static') {
|
||
payload.value = parseFloat(document.getElementById('value-source-value').value);
|
||
} else if (sourceType === 'animated') {
|
||
payload.waveform = document.getElementById('value-source-waveform').value;
|
||
payload.speed = parseFloat(document.getElementById('value-source-speed').value);
|
||
payload.min_value = parseFloat(document.getElementById('value-source-min-value').value);
|
||
payload.max_value = parseFloat(document.getElementById('value-source-max-value').value);
|
||
} else if (sourceType === 'audio') {
|
||
payload.audio_source_id = document.getElementById('value-source-audio-source').value;
|
||
payload.mode = document.getElementById('value-source-mode').value;
|
||
payload.auto_gain = document.getElementById('value-source-auto-gain').checked;
|
||
payload.sensitivity = parseFloat(document.getElementById('value-source-sensitivity').value);
|
||
payload.smoothing = parseFloat(document.getElementById('value-source-smoothing').value);
|
||
payload.min_value = parseFloat(document.getElementById('value-source-audio-min-value').value);
|
||
payload.max_value = parseFloat(document.getElementById('value-source-audio-max-value').value);
|
||
} else if (sourceType === 'adaptive_time') {
|
||
payload.schedule = _getScheduleFromUI();
|
||
if (payload.schedule.length < 2) {
|
||
errorEl.textContent = t('value_source.error.schedule_min');
|
||
errorEl.style.display = '';
|
||
return;
|
||
}
|
||
payload.min_value = parseFloat(document.getElementById('value-source-adaptive-min-value').value);
|
||
payload.max_value = parseFloat(document.getElementById('value-source-adaptive-max-value').value);
|
||
} else if (sourceType === 'adaptive_scene') {
|
||
payload.picture_source_id = document.getElementById('value-source-picture-source').value;
|
||
payload.scene_behavior = document.getElementById('value-source-scene-behavior').value;
|
||
payload.sensitivity = parseFloat(document.getElementById('value-source-scene-sensitivity').value);
|
||
payload.smoothing = parseFloat(document.getElementById('value-source-scene-smoothing').value);
|
||
payload.min_value = parseFloat(document.getElementById('value-source-adaptive-min-value').value);
|
||
payload.max_value = parseFloat(document.getElementById('value-source-adaptive-max-value').value);
|
||
}
|
||
|
||
try {
|
||
const method = id ? 'PUT' : 'POST';
|
||
const url = id ? `/value-sources/${id}` : '/value-sources';
|
||
const resp = await fetchWithAuth(url, {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||
}
|
||
showToast(t(id ? 'value_source.updated' : 'value_source.created'), 'success');
|
||
valueSourceModal.forceClose();
|
||
await loadPictureSources();
|
||
} catch (e) {
|
||
errorEl.textContent = e.message;
|
||
errorEl.style.display = '';
|
||
}
|
||
}
|
||
|
||
// ── Edit ──────────────────────────────────────────────────────
|
||
|
||
export async function editValueSource(sourceId) {
|
||
try {
|
||
const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
|
||
if (!resp.ok) throw new Error('fetch failed');
|
||
const data = await resp.json();
|
||
await showValueSourceModal(data);
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ── Clone ─────────────────────────────────────────────────────
|
||
|
||
export async function cloneValueSource(sourceId) {
|
||
try {
|
||
const resp = await fetchWithAuth(`/value-sources/${sourceId}`);
|
||
if (!resp.ok) throw new Error('fetch failed');
|
||
const data = await resp.json();
|
||
delete data.id;
|
||
data.name = data.name + ' (copy)';
|
||
await showValueSourceModal(data);
|
||
} catch (e) {
|
||
if (e.isAuth) return;
|
||
showToast(e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ── Delete ────────────────────────────────────────────────────
|
||
|
||
export async function deleteValueSource(sourceId) {
|
||
const confirmed = await showConfirm(t('value_source.delete.confirm'));
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
const resp = await fetchWithAuth(`/value-sources/${sourceId}`, { method: 'DELETE' });
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||
}
|
||
showToast(t('value_source.deleted'), 'success');
|
||
await loadPictureSources();
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ── Value Source Test (real-time output chart) ────────────────
|
||
|
||
const VS_HISTORY_SIZE = 200;
|
||
|
||
let _testVsWs = null;
|
||
let _testVsAnimFrame = null;
|
||
let _testVsLatest = null;
|
||
let _testVsHistory = [];
|
||
let _testVsMinObserved = Infinity;
|
||
let _testVsMaxObserved = -Infinity;
|
||
|
||
const testVsModal = new Modal('test-value-source-modal', { backdrop: true, lock: true });
|
||
|
||
export function testValueSource(sourceId) {
|
||
const statusEl = document.getElementById('vs-test-status');
|
||
if (statusEl) {
|
||
statusEl.textContent = t('value_source.test.connecting');
|
||
statusEl.style.display = '';
|
||
}
|
||
|
||
// Reset state
|
||
_testVsLatest = null;
|
||
_testVsHistory = [];
|
||
_testVsMinObserved = Infinity;
|
||
_testVsMaxObserved = -Infinity;
|
||
|
||
document.getElementById('vs-test-current').textContent = '---';
|
||
document.getElementById('vs-test-min').textContent = '---';
|
||
document.getElementById('vs-test-max').textContent = '---';
|
||
|
||
testVsModal.open();
|
||
|
||
// Size canvas to container
|
||
const canvas = document.getElementById('vs-test-canvas');
|
||
_sizeVsCanvas(canvas);
|
||
|
||
// Connect WebSocket
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/value-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}`;
|
||
|
||
try {
|
||
_testVsWs = new WebSocket(wsUrl);
|
||
|
||
_testVsWs.onopen = () => {
|
||
if (statusEl) statusEl.style.display = 'none';
|
||
};
|
||
|
||
_testVsWs.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
_testVsLatest = data.value;
|
||
_testVsHistory.push(data.value);
|
||
if (_testVsHistory.length > VS_HISTORY_SIZE) {
|
||
_testVsHistory.shift();
|
||
}
|
||
if (data.value < _testVsMinObserved) _testVsMinObserved = data.value;
|
||
if (data.value > _testVsMaxObserved) _testVsMaxObserved = data.value;
|
||
} catch {}
|
||
};
|
||
|
||
_testVsWs.onclose = () => {
|
||
_testVsWs = null;
|
||
};
|
||
|
||
_testVsWs.onerror = () => {
|
||
showToast(t('value_source.test.error'), 'error');
|
||
_cleanupVsTest();
|
||
};
|
||
} catch {
|
||
showToast(t('value_source.test.error'), 'error');
|
||
_cleanupVsTest();
|
||
return;
|
||
}
|
||
|
||
// Start render loop
|
||
_testVsAnimFrame = requestAnimationFrame(_renderVsTestLoop);
|
||
}
|
||
|
||
export function closeTestValueSourceModal() {
|
||
_cleanupVsTest();
|
||
testVsModal.forceClose();
|
||
}
|
||
|
||
function _cleanupVsTest() {
|
||
if (_testVsAnimFrame) {
|
||
cancelAnimationFrame(_testVsAnimFrame);
|
||
_testVsAnimFrame = null;
|
||
}
|
||
if (_testVsWs) {
|
||
_testVsWs.onclose = null;
|
||
_testVsWs.close();
|
||
_testVsWs = null;
|
||
}
|
||
_testVsLatest = null;
|
||
}
|
||
|
||
function _sizeVsCanvas(canvas) {
|
||
const rect = canvas.parentElement.getBoundingClientRect();
|
||
const dpr = window.devicePixelRatio || 1;
|
||
canvas.width = rect.width * dpr;
|
||
canvas.height = 200 * dpr;
|
||
canvas.style.height = '200px';
|
||
canvas.getContext('2d').scale(dpr, dpr);
|
||
}
|
||
|
||
function _renderVsTestLoop() {
|
||
_renderVsChart();
|
||
if (testVsModal.isOpen) {
|
||
_testVsAnimFrame = requestAnimationFrame(_renderVsTestLoop);
|
||
}
|
||
}
|
||
|
||
function _renderVsChart() {
|
||
const canvas = document.getElementById('vs-test-canvas');
|
||
if (!canvas) return;
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = canvas.width / dpr;
|
||
const h = canvas.height / dpr;
|
||
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.clearRect(0, 0, w, h);
|
||
|
||
// Draw horizontal guide lines at 0.0, 0.5, 1.0
|
||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.lineWidth = 1;
|
||
for (const frac of [0, 0.5, 1.0]) {
|
||
const y = h - frac * h;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, y);
|
||
ctx.lineTo(w, y);
|
||
ctx.stroke();
|
||
}
|
||
ctx.setLineDash([]);
|
||
|
||
// Draw Y-axis labels
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
||
ctx.font = '10px monospace';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText('1.0', 4, 12);
|
||
ctx.fillText('0.5', 4, h / 2 - 2);
|
||
ctx.fillText('0.0', 4, h - 4);
|
||
|
||
const history = _testVsHistory;
|
||
if (history.length < 2) return;
|
||
|
||
// Draw filled area under the line
|
||
ctx.beginPath();
|
||
const stepX = w / (VS_HISTORY_SIZE - 1);
|
||
const startOffset = VS_HISTORY_SIZE - history.length;
|
||
|
||
ctx.moveTo(startOffset * stepX, h);
|
||
for (let i = 0; i < history.length; i++) {
|
||
const x = (startOffset + i) * stepX;
|
||
const y = h - history[i] * h;
|
||
ctx.lineTo(x, y);
|
||
}
|
||
ctx.lineTo((startOffset + history.length - 1) * stepX, h);
|
||
ctx.closePath();
|
||
ctx.fillStyle = 'rgba(76, 175, 80, 0.15)';
|
||
ctx.fill();
|
||
|
||
// Draw the line
|
||
ctx.beginPath();
|
||
for (let i = 0; i < history.length; i++) {
|
||
const x = (startOffset + i) * stepX;
|
||
const y = h - history[i] * h;
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
ctx.strokeStyle = '#4caf50';
|
||
ctx.lineWidth = 2;
|
||
ctx.stroke();
|
||
|
||
// Update stats
|
||
if (_testVsLatest !== null) {
|
||
document.getElementById('vs-test-current').textContent = (_testVsLatest * 100).toFixed(1) + '%';
|
||
}
|
||
if (_testVsMinObserved !== Infinity) {
|
||
document.getElementById('vs-test-min').textContent = (_testVsMinObserved * 100).toFixed(1) + '%';
|
||
}
|
||
if (_testVsMaxObserved !== -Infinity) {
|
||
document.getElementById('vs-test-max').textContent = (_testVsMaxObserved * 100).toFixed(1) + '%';
|
||
}
|
||
}
|
||
|
||
// ── Card rendering (used by streams.js) ───────────────────────
|
||
|
||
export function createValueSourceCard(src) {
|
||
const icon = getValueSourceIcon(src.source_type);
|
||
|
||
let propsHtml = '';
|
||
if (src.source_type === 'static') {
|
||
propsHtml = `<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>
|
||
<span class="stream-card-prop">↕️ ${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">📍 ${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')}">✕</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="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>
|
||
</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;
|
||
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('');
|
||
}
|
||
|
||
// ── 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()">✕</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));
|
||
}
|
||
}
|