Add EntitySelect/IconSelect UI improvements across modals

- Portal IconSelect popups to document.body with position:fixed to prevent
  clipping by modal overflow-y:auto
- Replace custom scene selectors in automation editor with EntitySelect
  command-palette pickers (main scene + fallback scene)
- Add IconSelect grid for automation deactivation mode (none/revert/fallback)
- Add IconSelect grid for automation condition type and match type
- Replace mapped zone source dropdowns with EntitySelect pickers
- Replace scene target selector with EntityPalette.pick() pattern
- Remove effect palette preview bar from CSS editor
- Remove sensitivity badge from audio color strip source cards
- Clean up unused scene-selector CSS and scene-target-add-row CSS
- Add locale keys for all new UI elements across en/ru/zh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 16:00:30 +03:00
parent 186940124c
commit 2712c6682e
32 changed files with 1204 additions and 391 deletions

View File

@@ -108,7 +108,7 @@ import {
// Layer 5: color-strip sources
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange, updateEffectPreview,
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange,
colorCycleAddColor, colorCycleRemoveColor,
compositeAddLayer, compositeRemoveLayer,
mappedAddZone, mappedRemoveZone,
@@ -142,6 +142,11 @@ import {
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
showCSSCalibration, toggleCalibrationOverlay,
} from './features/calibration.js';
import {
showAdvancedCalibration, closeAdvancedCalibration, saveAdvancedCalibration,
addCalibrationLine, removeCalibrationLine, selectCalibrationLine, moveCalibrationLine,
updateCalibrationLine, resetCalibrationView,
} from './features/advanced-calibration.js';
// Layer 6: tabs, navigation, command palette, settings
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.js';
@@ -371,7 +376,6 @@ Object.assign(window, {
onEffectTypeChange,
onCSSClockChange,
onAnimationTypeChange,
updateEffectPreview,
colorCycleAddColor,
colorCycleRemoveColor,
compositeAddLayer,
@@ -421,6 +425,17 @@ Object.assign(window, {
showCSSCalibration,
toggleCalibrationOverlay,
// advanced calibration
showAdvancedCalibration,
closeAdvancedCalibration,
saveAdvancedCalibration,
addCalibrationLine,
removeCalibrationLine,
selectCalibrationLine,
moveCalibrationLine,
updateCalibrationLine,
resetCalibrationView,
// tabs / navigation / command palette
switchTab,
startAutoRefresh,

View File

@@ -64,7 +64,7 @@ export class IconSelect {
// Hide the native select
this._select.style.display = 'none';
// Build trigger button
// Build trigger button (inserted next to select)
this._trigger = document.createElement('button');
this._trigger.type = 'button';
this._trigger.className = 'icon-select-trigger';
@@ -74,13 +74,13 @@ export class IconSelect {
});
this._select.parentNode.insertBefore(this._trigger, this._select.nextSibling);
// Build popup
// Build popup (portaled to body to avoid overflow clipping)
this._popup = document.createElement('div');
this._popup.className = POPUP_CLASS;
this._popup.addEventListener('click', (e) => e.stopPropagation());
this._popup.addEventListener('transitionend', this._onTransitionEnd);
this._popup.innerHTML = this._buildGrid();
this._select.parentNode.insertBefore(this._popup, this._trigger.nextSibling);
document.body.appendChild(this._popup);
// Bind item clicks
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
@@ -125,10 +125,28 @@ export class IconSelect {
});
}
_positionPopup() {
const rect = this._trigger.getBoundingClientRect();
this._popup.style.left = rect.left + 'px';
this._popup.style.width = Math.max(rect.width, 200) + 'px';
// Check if there's enough space below, otherwise open upward
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
if (spaceBelow < 250 && spaceAbove > spaceBelow) {
this._popup.style.top = '';
this._popup.style.bottom = (window.innerHeight - rect.top) + 'px';
} else {
this._popup.style.top = rect.bottom + 'px';
this._popup.style.bottom = '';
}
}
_toggle() {
const wasOpen = this._popup.classList.contains('open');
closeAllIconSelects();
if (!wasOpen) {
this._positionPopup();
this._popup.classList.add('open');
}
}

View File

@@ -17,6 +17,7 @@ const _svg = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb), key_colors: _svg(P.palette) };
const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image) };
const _colorStripTypeIcons = {
picture_advanced: _svg(P.monitor),
static: _svg(P.palette), color_cycle: _svg(P.refreshCw), gradient: _svg(P.rainbow),
effect: _svg(P.zap), composite: _svg(P.link),
mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin),

View File

@@ -13,6 +13,7 @@ import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICO
import * as P from '../core/icon-paths.js';
import { wrapCard } from '../core/card-colors.js';
import { IconSelect } from '../core/icon-select.js';
import { EntitySelect } from '../core/entity-palette.js';
import { attachProcessPicker } from '../core/process-picker.js';
import { csScenes, createSceneCard } from './scene-presets.js';
@@ -227,6 +228,7 @@ export async function openAutomationEditor(automationId, cloneData) {
condList.innerHTML = '';
_ensureConditionLogicIconSelect();
_ensureDeactivationModeIconSelect();
// Fetch scenes for selector
try {
@@ -235,6 +237,7 @@ export async function openAutomationEditor(automationId, cloneData) {
// Reset deactivation mode
document.getElementById('automation-deactivation-mode').value = 'none';
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none');
document.getElementById('automation-fallback-scene-group').style.display = 'none';
if (automationId) {
@@ -255,12 +258,14 @@ export async function openAutomationEditor(automationId, cloneData) {
}
// Scene selector
_initSceneSelector('automation-scene', automation.scene_preset_id);
_initSceneSelector('automation-scene-id', automation.scene_preset_id);
// Deactivation mode
document.getElementById('automation-deactivation-mode').value = automation.deactivation_mode || 'none';
const deactMode = automation.deactivation_mode || 'none';
document.getElementById('automation-deactivation-mode').value = deactMode;
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode);
_onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene', automation.deactivation_scene_preset_id);
_initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id);
} catch (e) {
showToast(e.message, 'error');
return;
@@ -281,11 +286,13 @@ export async function openAutomationEditor(automationId, cloneData) {
addAutomationConditionRow(clonedCond);
}
_initSceneSelector('automation-scene', cloneData.scene_preset_id);
_initSceneSelector('automation-scene-id', cloneData.scene_preset_id);
document.getElementById('automation-deactivation-mode').value = cloneData.deactivation_mode || 'none';
const cloneDeactMode = cloneData.deactivation_mode || 'none';
document.getElementById('automation-deactivation-mode').value = cloneDeactMode;
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode);
_onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene', cloneData.deactivation_scene_preset_id);
_initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id);
} else {
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
idInput.value = '';
@@ -293,8 +300,8 @@ export async function openAutomationEditor(automationId, cloneData) {
enabledInput.checked = true;
logicSelect.value = 'or';
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue('or');
_initSceneSelector('automation-scene', null);
_initSceneSelector('automation-fallback-scene', null);
_initSceneSelector('automation-scene-id', null);
_initSceneSelector('automation-fallback-scene-id', null);
}
// Wire up deactivation mode change
@@ -319,102 +326,60 @@ export async function closeAutomationEditorModal() {
await automationModal.close();
}
// ===== Scene selector logic =====
// ===== Scene selector (EntitySelect) =====
function _initSceneSelector(prefix, selectedId) {
const hiddenInput = document.getElementById(`${prefix}-id`);
const searchInput = document.getElementById(`${prefix}-search`);
const clearBtn = document.getElementById(`${prefix}-clear`);
const dropdown = document.getElementById(`${prefix}-dropdown`);
let _sceneEntitySelect = null;
let _fallbackSceneEntitySelect = null;
hiddenInput.value = selectedId || '';
function _getSceneItems() {
return (scenePresetsCache.data || []).map(s => ({
value: s.id,
label: s.name,
icon: `<span class="scene-color-dot" style="background:${s.color || '#4fc3f7'}"></span>`,
}));
}
// Set initial display text
if (selectedId) {
const scene = scenePresetsCache.data.find(s => s.id === selectedId);
searchInput.value = scene ? scene.name : '';
clearBtn.classList.toggle('visible', true);
} else {
searchInput.value = '';
clearBtn.classList.toggle('visible', false);
}
function _initSceneSelector(selectId, selectedId) {
const sel = document.getElementById(selectId);
// Populate <select> with scene options
sel.innerHTML = (scenePresetsCache.data || []).map(s =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
sel.value = selectedId || '';
// Render dropdown items
function renderDropdown(filter) {
const query = (filter || '').toLowerCase();
const filtered = query ? scenePresetsCache.data.filter(s => s.name.toLowerCase().includes(query)) : scenePresetsCache.data;
// Determine which EntitySelect slot to use
const isMain = selectId === 'automation-scene-id';
const existing = isMain ? _sceneEntitySelect : _fallbackSceneEntitySelect;
if (existing) existing.destroy();
if (filtered.length === 0) {
dropdown.innerHTML = `<div class="scene-selector-empty">${t('automations.scene.none_available')}</div>`;
} else {
dropdown.innerHTML = filtered.map(s => {
const selected = s.id === hiddenInput.value ? ' selected' : '';
return `<div class="scene-selector-item${selected}" data-scene-id="${s.id}"><span class="scene-color-dot" style="background:${escapeHtml(s.color || '#4fc3f7')}"></span>${escapeHtml(s.name)}</div>`;
}).join('');
}
const es = new EntitySelect({
target: sel,
getItems: _getSceneItems,
placeholder: t('automations.scene.search_placeholder'),
allowNone: true,
noneLabel: t('automations.scene.none_selected'),
});
if (isMain) _sceneEntitySelect = es; else _fallbackSceneEntitySelect = es;
}
// Attach click handlers
dropdown.querySelectorAll('.scene-selector-item').forEach(item => {
item.addEventListener('click', () => {
const id = item.dataset.sceneId;
const scene = scenePresetsCache.data.find(s => s.id === id);
hiddenInput.value = id;
searchInput.value = scene ? scene.name : '';
clearBtn.classList.toggle('visible', true);
dropdown.classList.remove('open');
});
});
}
// ===== Deactivation mode IconSelect =====
// Show dropdown on focus/click
searchInput.onfocus = () => {
renderDropdown(searchInput.value);
dropdown.classList.add('open');
};
const DEACT_MODE_KEYS = ['none', 'revert', 'fallback_scene'];
const DEACT_MODE_ICONS = {
none: P.pause, revert: P.undo2, fallback_scene: P.sparkles,
};
let _deactivationModeIconSelect = null;
searchInput.oninput = () => {
renderDropdown(searchInput.value);
dropdown.classList.add('open');
// If text doesn't match any scene, clear the hidden input
const exactMatch = scenePresetsCache.data.find(s => s.name.toLowerCase() === searchInput.value.toLowerCase());
if (!exactMatch) {
hiddenInput.value = '';
clearBtn.classList.toggle('visible', !!searchInput.value);
}
};
searchInput.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
// Select first visible item
const first = dropdown.querySelector('.scene-selector-item');
if (first) first.click();
} else if (e.key === 'Escape') {
dropdown.classList.remove('open');
searchInput.blur();
}
};
// Clear button
clearBtn.onclick = () => {
hiddenInput.value = '';
searchInput.value = '';
clearBtn.classList.remove('visible');
dropdown.classList.remove('open');
};
// Close dropdown when clicking outside
const selectorEl = searchInput.closest('.scene-selector');
// Remove old listener if any (re-init)
if (selectorEl._outsideClickHandler) {
document.removeEventListener('click', selectorEl._outsideClickHandler);
}
selectorEl._outsideClickHandler = (e) => {
if (!selectorEl.contains(e.target)) {
dropdown.classList.remove('open');
}
};
document.addEventListener('click', selectorEl._outsideClickHandler);
function _ensureDeactivationModeIconSelect() {
const sel = document.getElementById('automation-deactivation-mode');
if (!sel || _deactivationModeIconSelect) return;
const items = DEACT_MODE_KEYS.map(k => ({
value: k,
icon: _icon(DEACT_MODE_ICONS[k]),
label: t(`automations.deactivation_mode.${k}`),
desc: t(`automations.deactivation_mode.${k}.desc`),
}));
_deactivationModeIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
// ===== Condition editor =====
@@ -423,6 +388,36 @@ export function addAutomationCondition() {
addAutomationConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
}
const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook'];
const CONDITION_TYPE_ICONS = {
always: P.refreshCw, startup: P.power, application: P.smartphone,
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
mqtt: P.radio, webhook: P.globe,
};
const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen'];
const MATCH_TYPE_ICONS = {
running: P.play, topmost: P.monitor, topmost_fullscreen: P.tv, fullscreen: P.square,
};
function _buildMatchTypeItems() {
return MATCH_TYPE_KEYS.map(k => ({
value: k,
icon: _icon(MATCH_TYPE_ICONS[k]),
label: t(`automations.condition.application.match_type.${k}`),
desc: t(`automations.condition.application.match_type.${k}.desc`),
}));
}
function _buildConditionTypeItems() {
return CONDITION_TYPE_KEYS.map(k => ({
value: k,
icon: _icon(CONDITION_TYPE_ICONS[k]),
label: t(`automations.condition.${k}`),
desc: t(`automations.condition.${k}.desc`),
}));
}
function addAutomationConditionRow(condition) {
const list = document.getElementById('automation-conditions-list');
const row = document.createElement('div');
@@ -432,14 +427,7 @@ function addAutomationConditionRow(condition) {
row.innerHTML = `
<div class="condition-header">
<select class="condition-type-select">
<option value="always" ${condType === 'always' ? 'selected' : ''}>${t('automations.condition.always')}</option>
<option value="startup" ${condType === 'startup' ? 'selected' : ''}>${t('automations.condition.startup')}</option>
<option value="application" ${condType === 'application' ? 'selected' : ''}>${t('automations.condition.application')}</option>
<option value="time_of_day" ${condType === 'time_of_day' ? 'selected' : ''}>${t('automations.condition.time_of_day')}</option>
<option value="system_idle" ${condType === 'system_idle' ? 'selected' : ''}>${t('automations.condition.system_idle')}</option>
<option value="display_state" ${condType === 'display_state' ? 'selected' : ''}>${t('automations.condition.display_state')}</option>
<option value="mqtt" ${condType === 'mqtt' ? 'selected' : ''}>${t('automations.condition.mqtt')}</option>
<option value="webhook" ${condType === 'webhook' ? 'selected' : ''}>${t('automations.condition.webhook')}</option>
${CONDITION_TYPE_KEYS.map(k => `<option value="${k}" ${condType === k ? 'selected' : ''}>${t('automations.condition.' + k)}</option>`).join('')}
</select>
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove()" title="Remove">&#x2715;</button>
</div>
@@ -449,6 +437,13 @@ function addAutomationConditionRow(condition) {
const typeSelect = row.querySelector('.condition-type-select');
const container = row.querySelector('.condition-fields-container');
// Attach IconSelect to the condition type dropdown
const condIconSelect = new IconSelect({
target: typeSelect,
items: _buildConditionTypeItems(),
columns: 4,
});
function renderFields(type, data) {
if (type === 'always') {
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.always.hint')}</small>`;
@@ -585,6 +580,16 @@ function addAutomationConditionRow(condition) {
`;
const textarea = container.querySelector('.condition-apps');
attachProcessPicker(container, textarea);
// Attach IconSelect to match type
const matchSel = container.querySelector('.condition-match-type');
if (matchSel) {
new IconSelect({
target: matchSel,
items: _buildMatchTypeItems(),
columns: 2,
});
}
}
renderFields(condType, condition);

View File

@@ -81,7 +81,7 @@ let _cssClockEntitySelect = null;
/* ── Icon-grid type selector ──────────────────────────────────── */
const CSS_TYPE_KEYS = [
'picture', 'static', 'gradient', 'color_cycle',
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
'effect', 'composite', 'mapped', 'audio',
'api_input', 'notification',
];
@@ -118,7 +118,11 @@ export function onCSSTypeChange() {
const type = document.getElementById('css-editor-type').value;
// Sync icon-select trigger display
if (_cssTypeIconSelect) _cssTypeIconSelect.setValue(type);
document.getElementById('css-editor-picture-section').style.display = type === 'picture' ? '' : 'none';
const isPictureType = type === 'picture' || type === 'picture_advanced';
document.getElementById('css-editor-picture-section').style.display = isPictureType ? '' : 'none';
// Hide picture source dropdown for advanced (sources are per-line in calibration)
const psGroup = document.getElementById('css-editor-picture-source-group');
if (psGroup) psGroup.style.display = (type === 'picture') ? '' : 'none';
document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none';
document.getElementById('css-editor-color-cycle-section').style.display = type === 'color_cycle' ? '' : 'none';
document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none';
@@ -129,6 +133,7 @@ export function onCSSTypeChange() {
document.getElementById('css-editor-api-input-section').style.display = type === 'api_input' ? '' : 'none';
document.getElementById('css-editor-notification-section').style.display = type === 'notification' ? '' : 'none';
if (isPictureType) _ensureInterpolationIconSelect();
if (type === 'effect') {
_ensureEffectTypeIconSelect();
_ensureEffectPaletteIconSelect();
@@ -171,8 +176,8 @@ export function onCSSTypeChange() {
}
_syncAnimationSpeedState();
// LED count — only shown for picture, api_input, notification
const hasLedCount = ['picture', 'api_input'];
// LED count — only shown for picture, picture_advanced, api_input
const hasLedCount = ['picture', 'picture_advanced', 'api_input'];
document.getElementById('css-editor-led-count-group').style.display =
hasLedCount.includes(type) ? '' : 'none';
@@ -190,6 +195,8 @@ export function onCSSTypeChange() {
} else if (type === 'gradient') {
requestAnimationFrame(() => gradientRenderAll());
}
_autoGenerateCSSName();
}
function _populateClockDropdown(selectedId) {
@@ -275,6 +282,7 @@ function _gradientPresetStripHTML(stops, w = 80, h = 16) {
/* ── Effect / audio palette IconSelect instances ─────────────── */
let _interpolationIconSelect = null;
let _effectTypeIconSelect = null;
let _effectPaletteIconSelect = null;
let _audioPaletteIconSelect = null;
@@ -284,6 +292,18 @@ let _notificationEffectIconSelect = null;
const _icon = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
function _ensureInterpolationIconSelect() {
const sel = document.getElementById('css-editor-interpolation');
if (!sel) return;
const items = [
{ value: 'average', icon: _icon(P.slidersHorizontal), label: t('color_strip.interpolation.average'), desc: t('color_strip.interpolation.average.desc') },
{ value: 'median', icon: _icon(P.activity), label: t('color_strip.interpolation.median'), desc: t('color_strip.interpolation.median.desc') },
{ value: 'dominant', icon: _icon(P.target), label: t('color_strip.interpolation.dominant'), desc: t('color_strip.interpolation.dominant.desc') },
];
if (_interpolationIconSelect) { _interpolationIconSelect.updateItems(items); return; }
_interpolationIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
function _ensureEffectTypeIconSelect() {
const sel = document.getElementById('css-editor-effect-type');
if (!sel) return;
@@ -370,25 +390,6 @@ const _PALETTE_COLORS = {
};
// Default palette per effect type
const _EFFECT_DEFAULT_PALETTE = {
fire: 'fire', meteor: 'fire', plasma: 'rainbow', noise: 'rainbow', aurora: 'aurora',
};
export function updateEffectPreview() {
const el = document.getElementById('css-editor-effect-preview');
if (!el) return;
const et = document.getElementById('css-editor-effect-type').value;
if (et === 'meteor') {
const color = document.getElementById('css-editor-effect-color').value;
el.style.background = color;
} else {
const palette = document.getElementById('css-editor-effect-palette').value || _EFFECT_DEFAULT_PALETTE[et] || 'fire';
const pts = _PALETTE_COLORS[palette] || _PALETTE_COLORS.fire;
const stops = pts.map(([pos, rgb]) => `rgb(${rgb}) ${(pos * 100).toFixed(0)}%`).join(', ');
el.style.background = `linear-gradient(to right, ${stops})`;
}
}
export function onEffectTypeChange() {
const et = document.getElementById('css-editor-effect-type').value;
// palette: all except meteor
@@ -410,8 +411,7 @@ export function onEffectTypeChange() {
descEl.textContent = desc;
descEl.style.display = desc ? '' : 'none';
}
// palette preview
updateEffectPreview();
_autoGenerateCSSName();
}
/* ── Color Cycle helpers ──────────────────────────────────────── */
@@ -594,10 +594,25 @@ function _loadCompositeState(css) {
let _mappedZones = [];
let _mappedAvailableSources = []; // non-mapped sources for zone dropdowns
let _mappedZoneEntitySelects = [];
function _getMappedSourceItems() {
return _mappedAvailableSources.map(s => ({
value: s.id,
label: s.name,
icon: getColorStripIcon(s.source_type),
}));
}
function _mappedDestroyEntitySelects() {
_mappedZoneEntitySelects.forEach(es => es.destroy());
_mappedZoneEntitySelects = [];
}
function _mappedRenderList() {
const list = document.getElementById('mapped-zones-list');
if (!list) return;
_mappedDestroyEntitySelects();
list.innerHTML = _mappedZones.map((zone, i) => {
const srcOptions = _mappedAvailableSources.map(s =>
`<option value="${s.id}"${zone.source_id === s.id ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
@@ -627,6 +642,15 @@ function _mappedRenderList() {
</div>
`;
}).join('');
// Attach EntitySelect to each zone's source dropdown
list.querySelectorAll('.mapped-zone-source').forEach(sel => {
_mappedZoneEntitySelects.push(new EntitySelect({
target: sel,
getItems: _getMappedSourceItems,
placeholder: t('color_strip.mapped.select_source'),
}));
});
}
export function mappedAddZone() {
@@ -703,6 +727,7 @@ export function onAudioVizChange() {
document.getElementById('css-editor-audio-color-peak-group').style.display = viz === 'vu_meter' ? '' : 'none';
// Mirror: spectrum only
document.getElementById('css-editor-audio-mirror-group').style.display = viz === 'spectrum' ? '' : 'none';
_autoGenerateCSSName();
}
async function _loadAudioSources() {
@@ -900,6 +925,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
const isAudio = source.source_type === 'audio';
const isApiInput = source.source_type === 'api_input';
const isNotification = source.source_type === 'notification';
const isPictureAdvanced = source.source_type === 'picture_advanced';
// Clock crosslink badge (replaces speed badge when clock is assigned)
const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null;
@@ -976,14 +1002,12 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
`;
} else if (isAudio) {
const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum';
const sensitivityVal = (source.sensitivity || 1.0).toFixed(1);
const vizMode = source.visualization_mode || 'spectrum';
const showPalette = (vizMode === 'spectrum' || vizMode === 'beat_pulse') && source.palette;
const audioPaletteLabel = showPalette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
propsHtml = `
<span class="stream-card-prop">${ICON_MUSIC} ${escapeHtml(vizLabel)}</span>
${audioPaletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.audio.palette')}">${ICON_PALETTE} ${escapeHtml(audioPaletteLabel)}</span>` : ''}
<span class="stream-card-prop" title="${t('color_strip.audio.sensitivity')}">${ICON_ACTIVITY} ${sensitivityVal}</span>
${source.audio_source_id ? (() => {
const as = audioSourceMap && audioSourceMap[source.audio_source_id];
const asName = as ? as.name : source.audio_source_id;
@@ -1014,6 +1038,22 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
</span>
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
`;
} else if (isPictureAdvanced) {
const cal = source.calibration || {};
const lines = cal.lines || [];
const totalLeds = lines.reduce((s, l) => s + (l.led_count || 0), 0);
const ledCount = (source.led_count > 0) ? source.led_count : totalLeds;
// Collect unique picture source names
const psIds = [...new Set(lines.map(l => l.picture_source_id).filter(Boolean))];
const psNames = psIds.map(id => {
const ps = pictureSourceMap && pictureSourceMap[id];
return ps ? ps.name : id;
});
propsHtml = `
<span class="stream-card-prop" title="${t('calibration.advanced.lines_title')}">${ICON_MAP_PIN} ${lines.length} ${t('calibration.advanced.lines_title').toLowerCase()}</span>
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${ledCount}</span>` : ''}
${psNames.length ? `<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">${ICON_LINK_SOURCE} ${escapeHtml(psNames.join(', '))}</span>` : ''}
`;
} else {
const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id];
const srcName = ps ? ps.name : source.picture_source_id || '—';
@@ -1032,8 +1072,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
}
const icon = getColorStripIcon(source.source_type);
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification)
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification);
const calibrationBtn = isPictureKind
? `<button class="btn btn-icon btn-secondary" onclick="${isPictureAdvanced ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
: '';
return wrapCard({
@@ -1057,6 +1098,33 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
});
}
/* ── Auto-name generation ─────────────────────────────────────── */
let _cssNameManuallyEdited = false;
function _autoGenerateCSSName() {
if (_cssNameManuallyEdited) return;
if (document.getElementById('css-editor-id').value) return; // edit mode
const type = document.getElementById('css-editor-type').value;
const typeLabel = t(`color_strip.type.${type}`);
let detail = '';
if (type === 'picture') {
const sel = document.getElementById('css-editor-picture-source');
const name = sel?.selectedOptions[0]?.textContent?.trim();
if (name) detail = name;
} else if (type === 'effect') {
const eff = document.getElementById('css-editor-effect-type').value;
if (eff) detail = t(`color_strip.effect.${eff}`);
} else if (type === 'audio') {
const viz = document.getElementById('css-editor-audio-viz').value;
if (viz) detail = t(`color_strip.audio.viz.${viz}`);
} else if (type === 'notification') {
const eff = document.getElementById('css-editor-notification-effect').value;
if (eff) detail = t(`color_strip.notification.effect.${eff}`);
}
document.getElementById('css-editor-name').value = detail ? `${typeLabel} · ${detail}` : typeLabel;
}
/* ── Editor open/close ────────────────────────────────────────── */
export async function showCSSEditor(cssId = null, cloneData = null) {
@@ -1149,9 +1217,10 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
} else if (sourceType === 'notification') {
_loadNotificationState(css);
} else {
sourceSelect.value = css.picture_source_id || '';
if (sourceType === 'picture') sourceSelect.value = css.picture_source_id || '';
document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
const smoothing = css.smoothing ?? 0.3;
document.getElementById('css-editor-smoothing').value = smoothing;
@@ -1213,6 +1282,7 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
document.getElementById('css-editor-type').value = 'picture';
onCSSTypeChange();
document.getElementById('css-editor-interpolation').value = 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
document.getElementById('css-editor-smoothing').value = 0.3;
document.getElementById('css-editor-smoothing-value').textContent = '0.30';
document.getElementById('css-editor-brightness').value = 1.0;
@@ -1248,8 +1318,15 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
{ position: 0.0, color: [255, 0, 0] },
{ position: 1.0, color: [0, 0, 255] },
]);
_autoGenerateCSSName();
}
// Auto-name wiring
_cssNameManuallyEdited = !!(cssId || cloneData);
document.getElementById('css-editor-name').oninput = () => { _cssNameManuallyEdited = true; };
document.getElementById('css-editor-picture-source').onchange = () => _autoGenerateCSSName();
document.getElementById('css-editor-notification-effect').onchange = () => _autoGenerateCSSName();
document.getElementById('css-editor-error').style.display = 'none';
cssEditorModal.snapshot();
cssEditorModal.open();
@@ -1385,6 +1462,18 @@ export async function saveCSSEditor() {
app_colors: _notificationGetAppColorsDict(),
};
if (!cssId) payload.source_type = 'notification';
} else if (sourceType === 'picture_advanced') {
payload = {
name,
interpolation_mode: document.getElementById('css-editor-interpolation').value,
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
brightness: parseFloat(document.getElementById('css-editor-brightness').value),
saturation: parseFloat(document.getElementById('css-editor-saturation').value),
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
if (!cssId) payload.source_type = 'picture_advanced';
} else {
payload = {
name,

View File

@@ -13,6 +13,7 @@ import {
} from '../core/icons.js';
import { scenePresetsCache } from '../core/state.js';
import { cardColorStyle, cardColorButton } from '../core/card-colors.js';
import { EntityPalette } from '../core/entity-palette.js';
let _editingId = null;
let _allTargets = []; // fetched on capture open
@@ -153,13 +154,37 @@ export async function editScenePreset(presetId) {
document.getElementById('scene-preset-editor-description').value = preset.description || '';
document.getElementById('scene-preset-editor-error').style.display = 'none';
// Hide target selector in edit mode (metadata only)
const selectorGroup = document.getElementById('scene-target-selector-group');
if (selectorGroup) selectorGroup.style.display = 'none';
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.edit'); titleEl.textContent = t('scenes.edit'); }
// Show target selector and pre-populate with existing targets
const selectorGroup = document.getElementById('scene-target-selector-group');
const targetList = document.getElementById('scene-target-list');
if (selectorGroup && targetList) {
selectorGroup.style.display = '';
targetList.innerHTML = '';
try {
const resp = await fetchWithAuth('/output-targets');
if (resp.ok) {
const data = await resp.json();
_allTargets = data.targets || [];
// Pre-add targets already in the preset
const presetTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
for (const tid of presetTargetIds) {
const tgt = _allTargets.find(t => t.id === tid);
if (!tgt) continue;
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = tid;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">&#x2715;</button>`;
targetList.appendChild(item);
}
_refreshTargetSelect();
}
} catch { /* ignore */ }
}
scenePresetModal.open();
scenePresetModal.snapshot();
}
@@ -180,9 +205,11 @@ export async function saveScenePreset() {
try {
let resp;
if (_editingId) {
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
.map(el => el.dataset.targetId);
resp = await fetchWithAuth(`/scene-presets/${_editingId}`, {
method: 'PUT',
body: JSON.stringify({ name, description }),
body: JSON.stringify({ name, description, target_ids }),
});
} else {
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
@@ -216,42 +243,54 @@ export async function closeScenePresetEditor() {
// ===== Target selector helpers =====
function _refreshTargetSelect() {
const select = document.getElementById('scene-target-select');
if (!select) return;
const added = new Set(
function _getAddedTargetIds() {
return new Set(
[...document.querySelectorAll('#scene-target-list .scene-target-item')]
.map(el => el.dataset.targetId)
);
select.innerHTML = '';
for (const tgt of _allTargets) {
if (added.has(tgt.id)) continue;
const opt = document.createElement('option');
opt.value = tgt.id;
opt.textContent = tgt.name;
select.appendChild(opt);
}
// Disable add button when no targets available
const addBtn = select.parentElement?.querySelector('button');
if (addBtn) addBtn.disabled = select.options.length === 0;
}
export function addSceneTarget() {
const select = document.getElementById('scene-target-select');
function _refreshTargetSelect() {
// Update add button disabled state
const addBtn = document.getElementById('scene-target-add-btn');
if (addBtn) {
const added = _getAddedTargetIds();
addBtn.disabled = _allTargets.every(t => added.has(t.id));
}
}
function _addTargetToList(targetId, targetName) {
const list = document.getElementById('scene-target-list');
if (!select || !list || !select.value) return;
const targetId = select.value;
const targetName = select.options[select.selectedIndex].text;
if (!list) return;
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = targetId;
item.innerHTML = `<span>${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">&#x2715;</button>`;
list.appendChild(item);
_refreshTargetSelect();
}
export async function addSceneTarget() {
const added = _getAddedTargetIds();
const available = _allTargets.filter(t => !added.has(t.id));
if (available.length === 0) return;
const items = available.map(t => ({
value: t.id,
label: t.name,
icon: ICON_TARGET,
}));
const picked = await EntityPalette.pick({
items,
placeholder: t('scenes.targets.search_placeholder'),
});
if (!picked) return;
const tgt = _allTargets.find(t => t.id === picked);
if (tgt) _addTargetToList(tgt.id, tgt.name);
}
export function removeSceneTarget(btn) {
btn.closest('.scene-target-item').remove();
_refreshTargetSelect();