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:
@@ -174,103 +174,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Scene selector (searchable combobox) */
|
||||
.scene-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.scene-selector-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scene-selector-input {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.scene-selector-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scene-selector-clear.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scene-selector-clear:hover {
|
||||
color: var(--danger-color, #dc3545);
|
||||
}
|
||||
|
||||
.scene-selector-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
background: var(--bg-color);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.scene-selector-dropdown.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scene-selector-item {
|
||||
padding: 6px 10px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.scene-selector-item:hover {
|
||||
background: rgba(33, 150, 243, 0.15);
|
||||
}
|
||||
|
||||
.scene-selector-item.selected {
|
||||
background: rgba(33, 150, 243, 0.2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scene-selector-item .scene-color-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scene-selector-empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Webhook URL row */
|
||||
.webhook-url-row {
|
||||
display: flex;
|
||||
|
||||
@@ -450,3 +450,4 @@
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -443,13 +443,6 @@ textarea:focus-visible {
|
||||
.endpoint-label { display: block; font-weight: 600; margin-bottom: 2px; opacity: 0.7; font-size: 0.8em; }
|
||||
|
||||
/* Scene target selector */
|
||||
.scene-target-add-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.scene-target-add-row select { flex: 1; }
|
||||
.scene-target-add-row .btn { padding: 4px 10px; min-width: 0; flex: 0 0 auto; font-size: 0.85rem; }
|
||||
.scene-target-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -510,7 +503,8 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.icon-select-popup {
|
||||
position: relative;
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
|
||||
@@ -760,14 +760,6 @@
|
||||
|
||||
/* ── Gradient editor ────────────────────────────────────────────── */
|
||||
|
||||
.effect-palette-preview {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
margin-top: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.gradient-editor {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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">✕</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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">✕</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">✕</button>`;
|
||||
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">✕</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();
|
||||
|
||||
@@ -624,10 +624,13 @@
|
||||
"automations.conditions.add": "Add Condition",
|
||||
"automations.conditions.empty": "No conditions — automation is always active when enabled",
|
||||
"automations.condition.always": "Always",
|
||||
"automations.condition.always.desc": "Always active",
|
||||
"automations.condition.always.hint": "Automation activates immediately when enabled and stays active.",
|
||||
"automations.condition.startup": "Startup",
|
||||
"automations.condition.startup.desc": "On server start",
|
||||
"automations.condition.startup.hint": "Activates when the server starts and stays active while enabled.",
|
||||
"automations.condition.application": "Application",
|
||||
"automations.condition.application.desc": "App running/focused",
|
||||
"automations.condition.application.apps": "Applications:",
|
||||
"automations.condition.application.apps.hint": "Process names, one per line (e.g. firefox.exe)",
|
||||
"automations.condition.application.browse": "Browse",
|
||||
@@ -636,23 +639,31 @@
|
||||
"automations.condition.application.match_type": "Match Type:",
|
||||
"automations.condition.application.match_type.hint": "How to detect the application",
|
||||
"automations.condition.application.match_type.running": "Running",
|
||||
"automations.condition.application.match_type.topmost": "Topmost (foreground)",
|
||||
"automations.condition.application.match_type.topmost_fullscreen": "Topmost + Fullscreen",
|
||||
"automations.condition.application.match_type.running.desc": "Process is active",
|
||||
"automations.condition.application.match_type.topmost": "Topmost",
|
||||
"automations.condition.application.match_type.topmost.desc": "Foreground window",
|
||||
"automations.condition.application.match_type.topmost_fullscreen": "Topmost + FS",
|
||||
"automations.condition.application.match_type.topmost_fullscreen.desc": "Foreground + fullscreen",
|
||||
"automations.condition.application.match_type.fullscreen": "Fullscreen",
|
||||
"automations.condition.application.match_type.fullscreen.desc": "Any fullscreen app",
|
||||
"automations.condition.time_of_day": "Time of Day",
|
||||
"automations.condition.time_of_day.desc": "Time range",
|
||||
"automations.condition.time_of_day.start_time": "Start Time:",
|
||||
"automations.condition.time_of_day.end_time": "End Time:",
|
||||
"automations.condition.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:00–06:00), set start time after end time.",
|
||||
"automations.condition.system_idle": "System Idle",
|
||||
"automations.condition.system_idle.desc": "User idle/active",
|
||||
"automations.condition.system_idle.idle_minutes": "Idle Timeout (minutes):",
|
||||
"automations.condition.system_idle.mode": "Trigger Mode:",
|
||||
"automations.condition.system_idle.when_idle": "When idle",
|
||||
"automations.condition.system_idle.when_active": "When active",
|
||||
"automations.condition.display_state": "Display State",
|
||||
"automations.condition.display_state.desc": "Monitor on/off",
|
||||
"automations.condition.display_state.state": "Monitor State:",
|
||||
"automations.condition.display_state.on": "On",
|
||||
"automations.condition.display_state.off": "Off (sleeping)",
|
||||
"automations.condition.mqtt": "MQTT",
|
||||
"automations.condition.mqtt.desc": "MQTT message",
|
||||
"automations.condition.mqtt.topic": "Topic:",
|
||||
"automations.condition.mqtt.payload": "Payload:",
|
||||
"automations.condition.mqtt.match_mode": "Match Mode:",
|
||||
@@ -661,6 +672,7 @@
|
||||
"automations.condition.mqtt.match_mode.regex": "Regex",
|
||||
"automations.condition.mqtt.hint": "Activate when an MQTT topic receives a matching payload",
|
||||
"automations.condition.webhook": "Webhook",
|
||||
"automations.condition.webhook.desc": "HTTP callback",
|
||||
"automations.condition.webhook.hint": "Activate via an HTTP call from external services (Home Assistant, IFTTT, curl, etc.)",
|
||||
"automations.condition.webhook.url": "Webhook URL:",
|
||||
"automations.condition.webhook.copy": "Copy",
|
||||
@@ -673,9 +685,12 @@
|
||||
"automations.scene.none_available": "No scenes available",
|
||||
"automations.deactivation_mode": "Deactivation:",
|
||||
"automations.deactivation_mode.hint": "What happens when conditions stop matching",
|
||||
"automations.deactivation_mode.none": "None — keep current state",
|
||||
"automations.deactivation_mode.revert": "Revert to previous state",
|
||||
"automations.deactivation_mode.fallback_scene": "Activate fallback scene",
|
||||
"automations.deactivation_mode.none": "None",
|
||||
"automations.deactivation_mode.none.desc": "Keep current state",
|
||||
"automations.deactivation_mode.revert": "Revert",
|
||||
"automations.deactivation_mode.revert.desc": "Restore previous state",
|
||||
"automations.deactivation_mode.fallback_scene": "Fallback",
|
||||
"automations.deactivation_mode.fallback_scene.desc": "Activate a fallback scene",
|
||||
"automations.deactivation_scene": "Fallback Scene:",
|
||||
"automations.deactivation_scene.hint": "Scene to activate when this automation deactivates",
|
||||
"automations.status.active": "Active",
|
||||
@@ -702,6 +717,8 @@
|
||||
"scenes.description.hint": "Optional description of what this scene does",
|
||||
"scenes.targets": "Targets:",
|
||||
"scenes.targets.hint": "Select which targets to include in this scene snapshot",
|
||||
"scenes.targets.add": "Add Target",
|
||||
"scenes.targets.search_placeholder": "Search targets...",
|
||||
"scenes.capture": "Capture",
|
||||
"scenes.activate": "Activate scene",
|
||||
"scenes.recapture": "Recapture current state",
|
||||
@@ -747,6 +764,9 @@
|
||||
"color_strip.interpolation.average": "Average",
|
||||
"color_strip.interpolation.median": "Median",
|
||||
"color_strip.interpolation.dominant": "Dominant",
|
||||
"color_strip.interpolation.average.desc": "Blend all sampled pixels into a smooth color",
|
||||
"color_strip.interpolation.median.desc": "Pick the middle color, reducing outliers",
|
||||
"color_strip.interpolation.dominant.desc": "Use the most frequent color in the sample",
|
||||
"color_strip.smoothing": "Smoothing:",
|
||||
"color_strip.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
||||
"color_strip.frame_interpolation": "Frame Interpolation:",
|
||||
@@ -773,6 +793,8 @@
|
||||
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers. Audio Reactive drives LEDs from real-time audio input. API Input receives raw LED colors from external clients via REST or WebSocket.",
|
||||
"color_strip.type.picture": "Picture Source",
|
||||
"color_strip.type.picture.desc": "Colors from screen capture",
|
||||
"color_strip.type.picture_advanced": "Multi-Monitor",
|
||||
"color_strip.type.picture_advanced.desc": "Line-based calibration across monitors",
|
||||
"color_strip.type.static": "Static Color",
|
||||
"color_strip.type.static.desc": "Single solid color fill",
|
||||
"color_strip.type.gradient": "Gradient",
|
||||
@@ -910,6 +932,7 @@
|
||||
"color_strip.mapped.zone_end": "End LED",
|
||||
"color_strip.mapped.zone_reverse": "Reverse",
|
||||
"color_strip.mapped.zones_count": "zones",
|
||||
"color_strip.mapped.select_source": "Search sources...",
|
||||
"color_strip.mapped.error.no_source": "Each zone must have a source selected",
|
||||
"color_strip.audio.visualization": "Visualization:",
|
||||
"color_strip.audio.visualization.hint": "How audio data is rendered to LEDs.",
|
||||
@@ -1197,6 +1220,29 @@
|
||||
"calibration.error.save_failed": "Failed to save calibration",
|
||||
"calibration.error.led_count_mismatch": "Total LEDs must equal the device LED count",
|
||||
"calibration.error.led_count_exceeded": "Calibrated LEDs exceed the total LED count",
|
||||
"calibration.mode.simple": "Simple",
|
||||
"calibration.mode.advanced": "Advanced",
|
||||
"calibration.switch_to_advanced": "Switch to Advanced",
|
||||
"calibration.advanced.title": "Advanced Calibration",
|
||||
"calibration.advanced.switch_to_simple": "Switch to Simple",
|
||||
"calibration.advanced.lines_title": "Lines",
|
||||
"calibration.advanced.canvas_hint": "Drag monitors to reposition. Click edges to select lines. Scroll to zoom, drag empty space to pan.",
|
||||
"calibration.advanced.reset_view": "Reset view",
|
||||
"calibration.advanced.line_properties": "Line Properties",
|
||||
"calibration.advanced.picture_source": "Source:",
|
||||
"calibration.advanced.picture_source.hint": "The picture source (monitor) this line samples from",
|
||||
"calibration.advanced.edge": "Edge:",
|
||||
"calibration.advanced.edge.hint": "Which screen edge to sample pixels from",
|
||||
"calibration.advanced.led_count": "LEDs:",
|
||||
"calibration.advanced.led_count.hint": "Number of LEDs mapped to this line",
|
||||
"calibration.advanced.span_start": "Span Start:",
|
||||
"calibration.advanced.span_start.hint": "Where sampling begins along the edge (0 = start, 1 = end). Use to cover only part of an edge.",
|
||||
"calibration.advanced.span_end": "Span End:",
|
||||
"calibration.advanced.span_end.hint": "Where sampling ends along the edge (0 = start, 1 = end). Together with Span Start, defines the active portion.",
|
||||
"calibration.advanced.border_width": "Depth (px):",
|
||||
"calibration.advanced.border_width.hint": "How many pixels deep from the edge to sample. Larger values capture more of the screen interior.",
|
||||
"calibration.advanced.reverse": "Reverse",
|
||||
"calibration.advanced.no_lines_warning": "Add at least one line",
|
||||
"dashboard.error.automation_toggle_failed": "Failed to toggle automation",
|
||||
"dashboard.error.start_failed": "Failed to start processing",
|
||||
"dashboard.error.stop_failed": "Failed to stop processing",
|
||||
|
||||
@@ -624,10 +624,13 @@
|
||||
"automations.conditions.add": "Добавить условие",
|
||||
"automations.conditions.empty": "Нет условий — автоматизация всегда активна когда включена",
|
||||
"automations.condition.always": "Всегда",
|
||||
"automations.condition.always.desc": "Всегда активно",
|
||||
"automations.condition.always.hint": "Автоматизация активируется сразу при включении и остаётся активной.",
|
||||
"automations.condition.startup": "Автозапуск",
|
||||
"automations.condition.startup.desc": "При запуске сервера",
|
||||
"automations.condition.startup.hint": "Активируется при запуске сервера и остаётся активной пока включена.",
|
||||
"automations.condition.application": "Приложение",
|
||||
"automations.condition.application.desc": "Приложение запущено",
|
||||
"automations.condition.application.apps": "Приложения:",
|
||||
"automations.condition.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)",
|
||||
"automations.condition.application.browse": "Обзор",
|
||||
@@ -636,23 +639,31 @@
|
||||
"automations.condition.application.match_type": "Тип соответствия:",
|
||||
"automations.condition.application.match_type.hint": "Как определять наличие приложения",
|
||||
"automations.condition.application.match_type.running": "Запущено",
|
||||
"automations.condition.application.match_type.running.desc": "Процесс активен",
|
||||
"automations.condition.application.match_type.topmost": "На переднем плане",
|
||||
"automations.condition.application.match_type.topmost_fullscreen": "На переднем плане + Полный экран",
|
||||
"automations.condition.application.match_type.topmost.desc": "Окно в фокусе",
|
||||
"automations.condition.application.match_type.topmost_fullscreen": "Передний план + ПЭ",
|
||||
"automations.condition.application.match_type.topmost_fullscreen.desc": "В фокусе + полный экран",
|
||||
"automations.condition.application.match_type.fullscreen": "Полный экран",
|
||||
"automations.condition.application.match_type.fullscreen.desc": "Любое полноэкранное",
|
||||
"automations.condition.time_of_day": "Время суток",
|
||||
"automations.condition.time_of_day.desc": "Диапазон времени",
|
||||
"automations.condition.time_of_day.start_time": "Время начала:",
|
||||
"automations.condition.time_of_day.end_time": "Время окончания:",
|
||||
"automations.condition.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06:00) укажите время начала позже времени окончания.",
|
||||
"automations.condition.system_idle": "Бездействие системы",
|
||||
"automations.condition.system_idle.desc": "Бездействие/активность",
|
||||
"automations.condition.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):",
|
||||
"automations.condition.system_idle.mode": "Режим срабатывания:",
|
||||
"automations.condition.system_idle.when_idle": "При бездействии",
|
||||
"automations.condition.system_idle.when_active": "При активности",
|
||||
"automations.condition.display_state": "Состояние дисплея",
|
||||
"automations.condition.display_state.desc": "Монитор вкл/выкл",
|
||||
"automations.condition.display_state.state": "Состояние монитора:",
|
||||
"automations.condition.display_state.on": "Включён",
|
||||
"automations.condition.display_state.off": "Выключен (спящий режим)",
|
||||
"automations.condition.mqtt": "MQTT",
|
||||
"automations.condition.mqtt.desc": "MQTT сообщение",
|
||||
"automations.condition.mqtt.topic": "Топик:",
|
||||
"automations.condition.mqtt.payload": "Значение:",
|
||||
"automations.condition.mqtt.match_mode": "Режим сравнения:",
|
||||
@@ -661,6 +672,7 @@
|
||||
"automations.condition.mqtt.match_mode.regex": "Регулярное выражение",
|
||||
"automations.condition.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику",
|
||||
"automations.condition.webhook": "Вебхук",
|
||||
"automations.condition.webhook.desc": "HTTP вызов",
|
||||
"automations.condition.webhook.hint": "Активировать через HTTP-запрос от внешних сервисов (Home Assistant, IFTTT, curl и т.д.)",
|
||||
"automations.condition.webhook.url": "URL вебхука:",
|
||||
"automations.condition.webhook.copy": "Скопировать",
|
||||
@@ -673,9 +685,12 @@
|
||||
"automations.scene.none_available": "Нет доступных сцен",
|
||||
"automations.deactivation_mode": "Деактивация:",
|
||||
"automations.deactivation_mode.hint": "Что происходит, когда условия перестают выполняться",
|
||||
"automations.deactivation_mode.none": "Ничего — оставить текущее состояние",
|
||||
"automations.deactivation_mode.revert": "Вернуть предыдущее состояние",
|
||||
"automations.deactivation_mode.fallback_scene": "Активировать резервную сцену",
|
||||
"automations.deactivation_mode.none": "Ничего",
|
||||
"automations.deactivation_mode.none.desc": "Оставить текущее состояние",
|
||||
"automations.deactivation_mode.revert": "Откатить",
|
||||
"automations.deactivation_mode.revert.desc": "Вернуть предыдущее состояние",
|
||||
"automations.deactivation_mode.fallback_scene": "Резервная",
|
||||
"automations.deactivation_mode.fallback_scene.desc": "Активировать резервную сцену",
|
||||
"automations.deactivation_scene": "Резервная сцена:",
|
||||
"automations.deactivation_scene.hint": "Сцена для активации при деактивации автоматизации",
|
||||
"automations.status.active": "Активна",
|
||||
@@ -702,6 +717,8 @@
|
||||
"scenes.description.hint": "Необязательное описание назначения этой сцены",
|
||||
"scenes.targets": "Цели:",
|
||||
"scenes.targets.hint": "Выберите какие цели включить в снимок сцены",
|
||||
"scenes.targets.add": "Добавить цель",
|
||||
"scenes.targets.search_placeholder": "Поиск целей...",
|
||||
"scenes.capture": "Захват",
|
||||
"scenes.activate": "Активировать сцену",
|
||||
"scenes.recapture": "Перезахватить текущее состояние",
|
||||
@@ -747,6 +764,9 @@
|
||||
"color_strip.interpolation.average": "Среднее",
|
||||
"color_strip.interpolation.median": "Медиана",
|
||||
"color_strip.interpolation.dominant": "Доминирующий",
|
||||
"color_strip.interpolation.average.desc": "Смешивает все пиксели в усреднённый цвет",
|
||||
"color_strip.interpolation.median.desc": "Берёт средний цвет, игнорируя выбросы",
|
||||
"color_strip.interpolation.dominant.desc": "Использует самый частый цвет в выборке",
|
||||
"color_strip.smoothing": "Сглаживание:",
|
||||
"color_strip.smoothing.hint": "Временное смешивание кадров (0=без смешивания, 1=полное). Уменьшает мерцание.",
|
||||
"color_strip.frame_interpolation": "Интерполяция кадров:",
|
||||
@@ -773,6 +793,8 @@
|
||||
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои. Аудиореактив управляет LED от аудиосигнала в реальном времени. API-ввод принимает массивы цветов LED от внешних клиентов через REST или WebSocket.",
|
||||
"color_strip.type.picture": "Источник изображения",
|
||||
"color_strip.type.picture.desc": "Цвета из захвата экрана",
|
||||
"color_strip.type.picture_advanced": "Мультимонитор",
|
||||
"color_strip.type.picture_advanced.desc": "Калибровка линиями по нескольким мониторам",
|
||||
"color_strip.type.static": "Статический цвет",
|
||||
"color_strip.type.static.desc": "Заливка одним цветом",
|
||||
"color_strip.type.gradient": "Градиент",
|
||||
@@ -910,6 +932,7 @@
|
||||
"color_strip.mapped.zone_end": "Конец LED",
|
||||
"color_strip.mapped.zone_reverse": "Реверс",
|
||||
"color_strip.mapped.zones_count": "зон",
|
||||
"color_strip.mapped.select_source": "Поиск источников...",
|
||||
"color_strip.mapped.error.no_source": "Для каждой зоны должен быть выбран источник",
|
||||
"color_strip.audio.visualization": "Визуализация:",
|
||||
"color_strip.audio.visualization.hint": "Способ отображения аудиоданных на LED.",
|
||||
@@ -1197,6 +1220,29 @@
|
||||
"calibration.error.save_failed": "Не удалось сохранить калибровку",
|
||||
"calibration.error.led_count_mismatch": "Общее количество LED должно совпадать с количеством LED устройства",
|
||||
"calibration.error.led_count_exceeded": "Калиброванных LED больше, чем общее количество LED",
|
||||
"calibration.mode.simple": "Простой",
|
||||
"calibration.mode.advanced": "Расширенный",
|
||||
"calibration.switch_to_advanced": "Расширенный режим",
|
||||
"calibration.advanced.title": "Расширенная калибровка",
|
||||
"calibration.advanced.switch_to_simple": "Простой режим",
|
||||
"calibration.advanced.lines_title": "Линии",
|
||||
"calibration.advanced.canvas_hint": "Перетаскивайте мониторы. Нажимайте на грани для выбора линий. Прокрутка — масштаб, перетаскивание пустого места — сдвиг.",
|
||||
"calibration.advanced.reset_view": "Сбросить вид",
|
||||
"calibration.advanced.line_properties": "Свойства линии",
|
||||
"calibration.advanced.picture_source": "Источник:",
|
||||
"calibration.advanced.picture_source.hint": "Источник изображения (монитор), с которого эта линия снимает данные",
|
||||
"calibration.advanced.edge": "Грань:",
|
||||
"calibration.advanced.edge.hint": "С какой грани экрана снимать пиксели",
|
||||
"calibration.advanced.led_count": "Светодиоды:",
|
||||
"calibration.advanced.led_count.hint": "Количество светодиодов на этой линии",
|
||||
"calibration.advanced.span_start": "Начало:",
|
||||
"calibration.advanced.span_start.hint": "Откуда начинается захват вдоль грани (0 = начало, 1 = конец). Позволяет покрыть только часть грани.",
|
||||
"calibration.advanced.span_end": "Конец:",
|
||||
"calibration.advanced.span_end.hint": "Где заканчивается захват вдоль грани (0 = начало, 1 = конец). Вместе с «Начало» определяет активный участок.",
|
||||
"calibration.advanced.border_width": "Глубина (пкс):",
|
||||
"calibration.advanced.border_width.hint": "Сколько пикселей вглубь от края захватывать. Большие значения берут больше внутренней части экрана.",
|
||||
"calibration.advanced.reverse": "Реверс",
|
||||
"calibration.advanced.no_lines_warning": "Добавьте хотя бы одну линию",
|
||||
"dashboard.error.automation_toggle_failed": "Не удалось переключить автоматизацию",
|
||||
"dashboard.error.start_failed": "Не удалось запустить обработку",
|
||||
"dashboard.error.stop_failed": "Не удалось остановить обработку",
|
||||
|
||||
@@ -624,10 +624,13 @@
|
||||
"automations.conditions.add": "添加条件",
|
||||
"automations.conditions.empty": "无条件 — 启用后自动化始终处于活动状态",
|
||||
"automations.condition.always": "始终",
|
||||
"automations.condition.always.desc": "始终活跃",
|
||||
"automations.condition.always.hint": "自动化启用后立即激活并保持活动。",
|
||||
"automations.condition.startup": "启动",
|
||||
"automations.condition.startup.desc": "服务器启动时",
|
||||
"automations.condition.startup.hint": "服务器启动时激活,启用期间保持活动。",
|
||||
"automations.condition.application": "应用程序",
|
||||
"automations.condition.application.desc": "应用运行/聚焦",
|
||||
"automations.condition.application.apps": "应用程序:",
|
||||
"automations.condition.application.apps.hint": "进程名,每行一个(例如 firefox.exe)",
|
||||
"automations.condition.application.browse": "浏览",
|
||||
@@ -636,23 +639,31 @@
|
||||
"automations.condition.application.match_type": "匹配类型:",
|
||||
"automations.condition.application.match_type.hint": "如何检测应用程序",
|
||||
"automations.condition.application.match_type.running": "运行中",
|
||||
"automations.condition.application.match_type.topmost": "最前(前台)",
|
||||
"automations.condition.application.match_type.running.desc": "进程活跃",
|
||||
"automations.condition.application.match_type.topmost": "最前",
|
||||
"automations.condition.application.match_type.topmost.desc": "前台窗口",
|
||||
"automations.condition.application.match_type.topmost_fullscreen": "最前 + 全屏",
|
||||
"automations.condition.application.match_type.topmost_fullscreen.desc": "前台 + 全屏",
|
||||
"automations.condition.application.match_type.fullscreen": "全屏",
|
||||
"automations.condition.application.match_type.fullscreen.desc": "任意全屏应用",
|
||||
"automations.condition.time_of_day": "时段",
|
||||
"automations.condition.time_of_day.desc": "时间范围",
|
||||
"automations.condition.time_of_day.start_time": "开始时间:",
|
||||
"automations.condition.time_of_day.end_time": "结束时间:",
|
||||
"automations.condition.time_of_day.overnight_hint": "跨夜时段(如 22:00–06:00),请将开始时间设为晚于结束时间。",
|
||||
"automations.condition.system_idle": "系统空闲",
|
||||
"automations.condition.system_idle.desc": "空闲/活跃",
|
||||
"automations.condition.system_idle.idle_minutes": "空闲超时(分钟):",
|
||||
"automations.condition.system_idle.mode": "触发模式:",
|
||||
"automations.condition.system_idle.when_idle": "空闲时",
|
||||
"automations.condition.system_idle.when_active": "活跃时",
|
||||
"automations.condition.display_state": "显示器状态",
|
||||
"automations.condition.display_state.desc": "显示器开/关",
|
||||
"automations.condition.display_state.state": "显示器状态:",
|
||||
"automations.condition.display_state.on": "开启",
|
||||
"automations.condition.display_state.off": "关闭(休眠)",
|
||||
"automations.condition.mqtt": "MQTT",
|
||||
"automations.condition.mqtt.desc": "MQTT 消息",
|
||||
"automations.condition.mqtt.topic": "主题:",
|
||||
"automations.condition.mqtt.payload": "消息内容:",
|
||||
"automations.condition.mqtt.match_mode": "匹配模式:",
|
||||
@@ -661,6 +672,7 @@
|
||||
"automations.condition.mqtt.match_mode.regex": "正则表达式",
|
||||
"automations.condition.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活",
|
||||
"automations.condition.webhook": "Webhook",
|
||||
"automations.condition.webhook.desc": "HTTP 回调",
|
||||
"automations.condition.webhook.hint": "通过外部服务的 HTTP 请求激活(Home Assistant、IFTTT、curl 等)",
|
||||
"automations.condition.webhook.url": "Webhook URL:",
|
||||
"automations.condition.webhook.copy": "复制",
|
||||
@@ -673,9 +685,12 @@
|
||||
"automations.scene.none_available": "没有可用的场景",
|
||||
"automations.deactivation_mode": "停用方式:",
|
||||
"automations.deactivation_mode.hint": "条件不再满足时的行为",
|
||||
"automations.deactivation_mode.none": "无 — 保持当前状态",
|
||||
"automations.deactivation_mode.revert": "恢复到之前的状态",
|
||||
"automations.deactivation_mode.fallback_scene": "激活备用场景",
|
||||
"automations.deactivation_mode.none": "无",
|
||||
"automations.deactivation_mode.none.desc": "保持当前状态",
|
||||
"automations.deactivation_mode.revert": "恢复",
|
||||
"automations.deactivation_mode.revert.desc": "恢复到之前的状态",
|
||||
"automations.deactivation_mode.fallback_scene": "备用",
|
||||
"automations.deactivation_mode.fallback_scene.desc": "激活备用场景",
|
||||
"automations.deactivation_scene": "备用场景:",
|
||||
"automations.deactivation_scene.hint": "自动化停用时激活的场景",
|
||||
"automations.status.active": "活动",
|
||||
@@ -702,6 +717,8 @@
|
||||
"scenes.description.hint": "此场景功能的可选描述",
|
||||
"scenes.targets": "目标:",
|
||||
"scenes.targets.hint": "选择要包含在此场景快照中的目标",
|
||||
"scenes.targets.add": "添加目标",
|
||||
"scenes.targets.search_placeholder": "搜索目标...",
|
||||
"scenes.capture": "捕获",
|
||||
"scenes.activate": "激活场景",
|
||||
"scenes.recapture": "重新捕获当前状态",
|
||||
@@ -747,6 +764,9 @@
|
||||
"color_strip.interpolation.average": "平均",
|
||||
"color_strip.interpolation.median": "中位数",
|
||||
"color_strip.interpolation.dominant": "主色",
|
||||
"color_strip.interpolation.average.desc": "将所有采样像素混合为平滑颜色",
|
||||
"color_strip.interpolation.median.desc": "取中间颜色值,减少异常值",
|
||||
"color_strip.interpolation.dominant.desc": "使用样本中出现最频繁的颜色",
|
||||
"color_strip.smoothing": "平滑:",
|
||||
"color_strip.smoothing.hint": "帧间时间混合(0=无,1=完全)。减少闪烁。",
|
||||
"color_strip.frame_interpolation": "帧插值:",
|
||||
@@ -773,6 +793,8 @@
|
||||
"color_strip.type.hint": "图片源从屏幕采集推导 LED 颜色。静态颜色用单一颜色填充所有 LED。渐变在所有 LED 上分布颜色渐变。颜色循环平滑循环用户定义的颜色列表。组合将多个源作为混合图层叠加。音频响应从实时音频输入驱动 LED。API 输入通过 REST 或 WebSocket 从外部客户端接收原始 LED 颜色。",
|
||||
"color_strip.type.picture": "图片源",
|
||||
"color_strip.type.picture.desc": "从屏幕捕获获取颜色",
|
||||
"color_strip.type.picture_advanced": "多显示器",
|
||||
"color_strip.type.picture_advanced.desc": "跨显示器的线条校准",
|
||||
"color_strip.type.static": "静态颜色",
|
||||
"color_strip.type.static.desc": "单色填充",
|
||||
"color_strip.type.gradient": "渐变",
|
||||
@@ -910,6 +932,7 @@
|
||||
"color_strip.mapped.zone_end": "结束 LED",
|
||||
"color_strip.mapped.zone_reverse": "反转",
|
||||
"color_strip.mapped.zones_count": "个区域",
|
||||
"color_strip.mapped.select_source": "搜索源...",
|
||||
"color_strip.mapped.error.no_source": "每个区域必须选择一个源",
|
||||
"color_strip.audio.visualization": "可视化:",
|
||||
"color_strip.audio.visualization.hint": "音频数据如何渲染到 LED。",
|
||||
@@ -1197,6 +1220,29 @@
|
||||
"calibration.error.save_failed": "保存校准失败",
|
||||
"calibration.error.led_count_mismatch": "LED总数必须等于设备LED数量",
|
||||
"calibration.error.led_count_exceeded": "校准的LED超过了LED总数",
|
||||
"calibration.mode.simple": "简单",
|
||||
"calibration.mode.advanced": "高级",
|
||||
"calibration.switch_to_advanced": "切换到高级模式",
|
||||
"calibration.advanced.title": "高级校准",
|
||||
"calibration.advanced.switch_to_simple": "切换到简单模式",
|
||||
"calibration.advanced.lines_title": "线段",
|
||||
"calibration.advanced.canvas_hint": "拖动显示器重新排列。点击边缘选择线段。滚动缩放,拖动空白区域平移。",
|
||||
"calibration.advanced.reset_view": "重置视图",
|
||||
"calibration.advanced.line_properties": "线段属性",
|
||||
"calibration.advanced.picture_source": "来源:",
|
||||
"calibration.advanced.picture_source.hint": "此线段采样的图片来源(显示器)",
|
||||
"calibration.advanced.edge": "边缘:",
|
||||
"calibration.advanced.edge.hint": "从屏幕哪条边缘采样像素",
|
||||
"calibration.advanced.led_count": "LED数:",
|
||||
"calibration.advanced.led_count.hint": "映射到此线段的LED数量",
|
||||
"calibration.advanced.span_start": "起始位置:",
|
||||
"calibration.advanced.span_start.hint": "沿边缘开始采样的位置(0 = 起点,1 = 终点)。用于仅覆盖边缘的一部分。",
|
||||
"calibration.advanced.span_end": "结束位置:",
|
||||
"calibration.advanced.span_end.hint": "沿边缘结束采样的位置(0 = 起点,1 = 终点)。与起始位置一起定义活动区域。",
|
||||
"calibration.advanced.border_width": "深度(像素):",
|
||||
"calibration.advanced.border_width.hint": "从边缘向内采样多少像素。较大的值会捕获更多屏幕内部区域。",
|
||||
"calibration.advanced.reverse": "反转",
|
||||
"calibration.advanced.no_lines_warning": "请至少添加一条线段",
|
||||
"dashboard.error.automation_toggle_failed": "切换自动化失败",
|
||||
"dashboard.error.start_failed": "启动处理失败",
|
||||
"dashboard.error.stop_failed": "停止处理失败",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Navigation: network-first with offline fallback
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'ledgrab-v24';
|
||||
const CACHE_NAME = 'ledgrab-v33';
|
||||
|
||||
// Only pre-cache static assets (no auth required).
|
||||
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
|
||||
@@ -18,6 +18,7 @@ const PRECACHE_URLS = [
|
||||
'/static/css/cards.css',
|
||||
'/static/css/modal.css',
|
||||
'/static/css/calibration.css',
|
||||
'/static/css/advanced-calibration.css',
|
||||
'/static/css/dashboard.css',
|
||||
'/static/css/streams.css',
|
||||
'/static/css/patterns.css',
|
||||
|
||||
Reference in New Issue
Block a user