refactor: key colors targets → CSS source type, HA target improvements
Lint & Test / test (push) Successful in 1m26s

Key Colors refactor:
- New `key_colors` CSS source type with inline rectangles
- KeyColorsColorStripStream: extracts N colors from screen regions
- CSS editor: EntitySelect for picture source, IconSelect for color mode
- Configure Regions button on card opens pattern canvas editor
- Live WS preview at 5 FPS with rectangle overlay + color swatches
- Removed KC target type, pattern template entity, and related API routes
- Removed KC/pattern template sections from Targets tab

HA light target improvements:
- Update rate, transition, mappings, brightness VS now editable via PUT
- Card crosslinks for HA source, CSS source, brightness VS
- HA connection status icon, text metrics (Hz, uptime)
- Brightness value source selector in editor
This commit is contained in:
2026-03-28 15:28:22 +03:00
parent 89d1b13854
commit 3e6760f726
46 changed files with 2707 additions and 789 deletions
@@ -134,6 +134,11 @@
line-height: 1;
}
.btn-micro .icon {
width: 12px;
height: 12px;
}
.btn-micro:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--border-color);
@@ -83,8 +83,18 @@
border: none;
color: var(--danger-color, #dc3545);
cursor: pointer;
font-size: 1rem;
padding: 2px 6px;
opacity: 0.7;
transition: opacity 0.15s;
}
.btn-remove-condition:hover {
opacity: 1;
}
.btn-remove-condition .icon {
width: 16px;
height: 16px;
}
.condition-fields {
@@ -453,7 +453,6 @@ body.cs-drag-active .card-drag-handle {
background: none;
border: none;
color: var(--text-muted);
font-size: 1rem;
width: 28px;
height: 28px;
display: flex;
@@ -464,6 +463,11 @@ body.cs-drag-active .card-drag-handle {
transition: color 0.2s, background 0.2s;
}
.card-remove-btn .icon {
width: 16px;
height: 16px;
}
.card-remove-btn:hover {
color: var(--danger-color);
background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */
+198 -39
View File
@@ -956,11 +956,15 @@
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 0 4px;
line-height: 1;
}
.btn-icon-inline .icon {
width: 16px;
height: 16px;
}
.btn-danger-text {
color: var(--danger-color, #f44336);
}
@@ -1474,13 +1478,17 @@
}
.gradient-stop-remove-btn {
font-size: 0.75rem;
padding: 0;
width: 26px;
height: 26px;
flex: 0 0 26px;
}
.gradient-stop-remove-btn .icon {
width: 14px;
height: 14px;
}
.gradient-stop-bidir-btn.active {
background: var(--primary-color);
color: var(--primary-contrast);
@@ -1492,6 +1500,89 @@
flex: 1;
}
/* ── HA Light Mapping rows ────────────────────────────────── */
#ha-light-mappings-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 4px;
}
.ha-light-mapping-row {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px;
background: var(--bg-secondary, var(--bg-color));
}
.ha-mapping-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.ha-mapping-header .ha-mapping-label {
font-weight: 600;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
}
.ha-mapping-header .ha-mapping-label .icon {
width: 16px;
height: 16px;
opacity: 0.7;
}
.btn-remove-mapping {
background: none;
border: none;
color: var(--danger-color, #dc3545);
cursor: pointer;
padding: 2px 6px;
opacity: 0.7;
transition: opacity 0.15s;
}
.btn-remove-mapping .icon {
width: 16px;
height: 16px;
}
.btn-remove-mapping:hover {
opacity: 1;
}
.ha-mapping-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.ha-mapping-field label {
display: block;
font-size: 0.85rem;
margin-bottom: 3px;
color: var(--text-muted);
}
.ha-mapping-range-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
}
.ha-mapping-range-row label {
display: block;
font-size: 0.85rem;
margin-bottom: 3px;
color: var(--text-muted);
}
/* ── Custom gradient presets list ───────────────────────────── */
.custom-presets-list {
@@ -1547,7 +1638,6 @@
}
.color-cycle-remove-btn {
font-size: 0.6rem;
padding: 0;
width: 36px;
height: 14px;
@@ -1555,6 +1645,11 @@
line-height: 1;
}
.color-cycle-remove-btn .icon {
width: 12px;
height: 12px;
}
.color-cycle-add-btn {
width: 36px;
height: 28px;
@@ -1566,31 +1661,79 @@
/* ── Notification per-app overrides (unified color + sound) ──── */
.notif-override-row {
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: 4px 4px;
align-items: center;
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-color);
#notification-app-overrides-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 4px;
}
.notif-override-row .notif-override-name,
.notif-override-row .notif-override-sound {
.notif-override-row {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px;
background: var(--bg-secondary, var(--bg-color));
}
.notif-override-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.notif-override-label {
font-weight: 600;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
}
.notif-override-label .icon {
width: 16px;
height: 16px;
opacity: 0.7;
}
.btn-remove-override {
background: none;
border: none;
color: var(--danger-color, #dc3545);
cursor: pointer;
padding: 2px 6px;
opacity: 0.7;
transition: opacity 0.15s;
}
.btn-remove-override:hover {
opacity: 1;
}
.btn-remove-override .icon {
width: 16px;
height: 16px;
}
.notif-override-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.notif-override-app-row {
display: flex;
align-items: center;
gap: 6px;
}
.notif-override-app-row .notif-override-name {
flex: 1;
min-width: 0;
}
/* Sound select spans the first column, volume spans browse+color columns */
.notif-override-row .notif-override-sound {
grid-column: 1;
}
.notif-override-row .notif-override-volume {
grid-column: 2 / 4;
width: 100%;
}
.notif-override-row .notif-override-color {
.notif-override-app-row .notif-override-color {
width: 26px;
height: 26px;
border: 1px solid var(--border-color);
@@ -1598,6 +1741,24 @@
padding: 1px;
cursor: pointer;
background: transparent;
flex-shrink: 0;
}
.notif-override-sound-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
align-items: center;
}
.notif-override-sound-row .notif-override-sound,
.notif-override-sound-row .entity-select-trigger {
min-width: 0;
width: 100%;
}
.notif-override-sound-row .notif-override-volume {
width: 100%;
}
@@ -1671,7 +1832,7 @@
#composite-layers-list {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
margin-bottom: 8px;
}
@@ -1679,10 +1840,10 @@
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 8px;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--card-bg);
border-radius: 6px;
background: var(--bg-secondary, var(--bg-color));
}
.composite-layer-header {
@@ -1802,22 +1963,20 @@
.composite-layer-remove-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 0.85rem;
width: 26px;
height: 26px;
flex: 0 0 26px;
display: flex;
align-items: center;
justify-content: center;
color: var(--danger-color, #dc3545);
cursor: pointer;
border-radius: 4px;
transition: color 0.2s, background 0.2s;
padding: 2px 6px;
opacity: 0.7;
transition: opacity 0.15s;
}
.composite-layer-remove-btn .icon {
width: 16px;
height: 16px;
}
.composite-layer-remove-btn:hover {
color: var(--danger-color);
background: color-mix(in srgb, var(--danger-color) 10%, transparent);
opacity: 1;
}
.composite-layer-range-toggle-label {
@@ -348,7 +348,6 @@
background: none;
border: none;
color: var(--text-muted);
font-size: 0.9rem;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
@@ -356,6 +355,11 @@
transition: color 0.2s, background 0.2s;
}
.pattern-rect-row .pattern-rect-remove-btn .icon {
width: 14px;
height: 14px;
}
.pattern-rect-row .pattern-rect-remove-btn:hover {
color: var(--danger-color);
background: rgba(244, 67, 54, 0.1);
+3 -30
View File
@@ -73,20 +73,11 @@ import {
renderCSPTModalFilterList,
} from './features/streams.ts';
import {
createKCTargetCard, testKCTarget,
showKCEditor, closeKCEditorModal, forceCloseKCEditorModal, saveKCEditor,
deleteKCTarget, disconnectAllKCWebSockets,
updateKCBrightnessLabel, saveKCBrightness,
cloneKCTarget,
} from './features/kc-targets.ts';
import {
createPatternTemplateCard,
showPatternTemplateEditor, closePatternTemplateModal, forceClosePatternTemplateModal,
savePatternTemplate, deletePatternTemplate,
savePatternTemplate,
renderPatternRectList, selectPatternRect, updatePatternRect,
addPatternRect, deleteSelectedPatternRect, removePatternRect,
capturePatternBackground,
clonePatternTemplate,
} from './features/pattern-templates.ts';
import {
loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal,
@@ -109,7 +100,7 @@ import {
loadTargetsTab, switchTargetSubTab,
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
startTargetProcessing, stopTargetProcessing,
stopAllLedTargets, stopAllKCTargets,
stopAllLedTargets,
startTargetOverlay, stopTargetOverlay, deleteTarget,
cloneTarget, toggleLedPreview,
disconnectAllLedPreviewWS,
@@ -357,26 +348,11 @@ Object.assign(window, {
closeTestAudioTemplateModal,
startAudioTemplateTest,
// kc-targets
createKCTargetCard,
testKCTarget,
showKCEditor,
closeKCEditorModal,
forceCloseKCEditorModal,
saveKCEditor,
deleteKCTarget,
disconnectAllKCWebSockets,
updateKCBrightnessLabel,
saveKCBrightness,
cloneKCTarget,
// pattern-templates
createPatternTemplateCard,
// pattern-templates (canvas editor — used by key_colors CSS source)
showPatternTemplateEditor,
closePatternTemplateModal,
forceClosePatternTemplateModal,
savePatternTemplate,
deletePatternTemplate,
renderPatternRectList,
selectPatternRect,
updatePatternRect,
@@ -384,7 +360,6 @@ Object.assign(window, {
deleteSelectedPatternRect,
removePatternRect,
capturePatternBackground,
clonePatternTemplate,
// automations
loadAutomations,
@@ -427,7 +402,6 @@ Object.assign(window, {
startTargetProcessing,
stopTargetProcessing,
stopAllLedTargets,
stopAllKCTargets,
startTargetOverlay,
stopTargetOverlay,
deleteTarget,
@@ -642,7 +616,6 @@ window.addEventListener('beforeunload', () => {
}
stopConnectionMonitor();
stopEventsWS();
disconnectAllKCWebSockets();
disconnectAllLedPreviewWS();
});
@@ -20,6 +20,7 @@
*/
import { createColorPicker, registerColorPicker } from './color-picker.ts';
import { ICON_TRASH } from './icons.ts';
const STORAGE_KEY = 'cardColors';
const DEFAULT_SWATCH = '#808080';
@@ -115,7 +116,7 @@ export function wrapCard({
<div class="${type}${classes ? ' ' + classes : ''}" ${dataAttr}="${id}"${colorStyle ? ` style="${colorStyle}" data-has-color="1"` : ''}>
<div class="card-top-actions">
${topButtons}
${removeOnclick ? `<button class="card-remove-btn" onclick="${removeOnclick}" title="${removeTitle}">&#x2715;</button>` : ''}
${removeOnclick ? `<button class="card-remove-btn" onclick="${removeOnclick}" title="${removeTitle}">${ICON_TRASH}</button>` : ''}
</div>
${content}
<div class="${actionsClass}">
@@ -28,6 +28,7 @@ const _colorStripTypeIcons = {
candlelight: _svg(P.flame),
weather: _svg(P.cloudSun),
processed: _svg(P.sparkles),
key_colors: _svg(P.palette),
};
const _valueSourceTypeIcons = {
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
@@ -11,7 +11,7 @@ import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { getPictureSourceIcon } from '../core/icons.ts';
import { getPictureSourceIcon, ICON_TRASH } from '../core/icons.ts';
import type { Calibration, CalibrationLine, PictureSource } from '../types.ts';
/* ── Types ──────────────────────────────────────────────────── */
@@ -455,7 +455,7 @@ function _renderLineList(): void {
<span class="advcal-line-actions">
<button class="btn-micro" onclick="event.stopPropagation(); moveCalibrationLine(${i}, -1)" title="Move up" ${i === 0 ? 'disabled' : ''}>&#x25B2;</button>
<button class="btn-micro" onclick="event.stopPropagation(); moveCalibrationLine(${i}, 1)" title="Move down" ${i === _state.lines.length - 1 ? 'disabled' : ''}>&#x25BC;</button>
<button class="btn-micro btn-danger" onclick="event.stopPropagation(); removeCalibrationLine(${i})" title="Remove">&#x2715;</button>
<button class="btn-micro btn-danger" onclick="event.stopPropagation(); removeCalibrationLine(${i})" title="Remove">${ICON_TRASH}</button>
</span>
`;
container.appendChild(div);
@@ -8,7 +8,7 @@ import { _cachedValueSources, _cachedCSPTemplates } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import {
getColorStripIcon, getValueSourceIcon,
ICON_SPARKLES,
ICON_SPARKLES, ICON_TRASH,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts';
@@ -124,7 +124,7 @@ export function compositeRenderList() {
</label>
${canRemove
? `<button type="button" class="composite-layer-remove-btn"
onclick="compositeRemoveLayer(${i})" title="${t('common.delete')}">&#x2715;</button>`
onclick="compositeRemoveLayer(${i})" title="${t('common.delete')}">${ICON_TRASH}</button>`
: ''}
</div>
<div class="composite-layer-body-wrapper">
@@ -7,7 +7,7 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import {
ICON_SEARCH, ICON_CLONE, getAssetTypeIcon,
ICON_SEARCH, ICON_CLONE, ICON_TRASH, getAssetTypeIcon,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts';
@@ -112,17 +112,26 @@ function _overridesRenderList() {
const volPct = entry.volume ?? 100;
return `
<div class="notif-override-row">
<input type="text" class="notif-override-name" data-idx="${i}" value="${escapeHtml(entry.app)}"
placeholder="${t('color_strip.notification.app_overrides.app_placeholder') || 'App name'}">
<button type="button" class="btn btn-icon btn-secondary notif-override-browse" data-idx="${i}"
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
<input type="color" class="notif-override-color" data-idx="${i}" value="${entry.color}">
<button type="button" class="btn btn-icon btn-secondary"
onclick="notificationRemoveAppOverride(${i})">&#x2715;</button>
<select class="notif-override-sound" data-idx="${i}">${soundOpts}</select>
<input type="range" class="notif-override-volume" data-idx="${i}" min="0" max="100" step="5" value="${volPct}"
title="${volPct}%"
oninput="this.title = this.value + '%'">
<div class="notif-override-header">
<span class="notif-override-label">${_icon(P.bellRing)} #${i + 1}</span>
<button type="button" class="btn-remove-override"
onclick="notificationRemoveAppOverride(${i})" title="${t('common.delete')}">${ICON_TRASH}</button>
</div>
<div class="notif-override-fields">
<div class="notif-override-app-row">
<input type="text" class="notif-override-name" data-idx="${i}" value="${escapeHtml(entry.app)}"
placeholder="${t('color_strip.notification.app_overrides.app_placeholder') || 'App name'}">
<button type="button" class="btn btn-icon btn-secondary notif-override-browse" data-idx="${i}"
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
<input type="color" class="notif-override-color" data-idx="${i}" value="${entry.color}">
</div>
<div class="notif-override-sound-row">
<select class="notif-override-sound" data-idx="${i}">${soundOpts}</select>
<input type="range" class="notif-override-volume" data-idx="${i}" min="0" max="100" step="5" value="${volPct}"
title="${volPct}%"
oninput="this.title = this.value + '%'">
</div>
</div>
</div>`;
}).join('');
@@ -6,7 +6,7 @@
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { colorStripSourcesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import { showToast, openLightbox, closeLightbox } from '../core/ui.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import {
getColorStripIcon,
@@ -143,15 +143,145 @@ function _populateCssTestSourceSelector(preselectId: any) {
export function testColorStrip(sourceId: string) {
_cssTestCSPTMode = false;
_cssTestCSPTId = null;
// Detect api_input type
// Detect source type
const sources = (colorStripSourcesCache.data || []) as any[];
const src = sources.find(s => s.id === sourceId);
// Key Colors sources use a frame + rectangle overlay test (not the strip WS renderer)
if (src?.source_type === 'key_colors') {
_testKeyColorsSource(sourceId);
return;
}
_cssTestIsApiInput = src?.source_type === 'api_input';
// Populate input source selector with current source preselected
_populateCssTestSourceSelector(sourceId);
_openTestModal(sourceId);
}
let _kcTestWs: WebSocket | null = null;
const _kcTestCanvas = document.createElement('canvas');
const BORDER_COLORS = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96e6a1', '#dda0dd', '#f9ca24', '#ff9ff3', '#54a0ff'];
function _testKeyColorsSource(sourceId: string) {
// Show lightbox with spinner
const lightbox = document.getElementById('image-lightbox')!;
const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null;
const img = document.getElementById('lightbox-image') as HTMLImageElement;
img.src = '';
if (spinner) spinner.style.display = '';
document.getElementById('lightbox-stats')!.style.display = 'none';
lightbox.classList.add('active');
// Close any previous WS
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
// Build WS URL
const loc = window.location;
const wsProto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
const apiKey = (window as any).apiKey || localStorage.getItem('wled_api_key') || '';
const wsUrl = `${wsProto}//${loc.host}/api/v1/color-strip-sources/${sourceId}/key-colors/test/ws?token=${encodeURIComponent(apiKey)}&fps=5&preview_width=960`;
const ws = new WebSocket(wsUrl);
_kcTestWs = ws;
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
if (data.type === 'frame') {
_renderKCTestFrame(data);
}
} catch {}
};
ws.onerror = () => {
showToast('Key Colors test connection failed', 'error');
closeLightbox();
};
ws.onclose = () => {
_kcTestWs = null;
};
// Stop WS when lightbox closes
const origClose = (window as any).closeLightbox;
lightbox.onclick = (e) => {
if ((e.target as HTMLElement).closest('.lightbox-content')) return;
if (_kcTestWs) { _kcTestWs.close(); _kcTestWs = null; }
closeLightbox();
};
}
function _renderKCTestFrame(data: any) {
const rects = data.rectangles || [];
const mode = data.interpolation_mode || 'average';
// Draw frame + rectangles onto offscreen canvas
const tmpImg = new Image();
tmpImg.onload = () => {
_kcTestCanvas.width = tmpImg.naturalWidth;
_kcTestCanvas.height = tmpImg.naturalHeight;
const ctx = _kcTestCanvas.getContext('2d')!;
ctx.drawImage(tmpImg, 0, 0);
rects.forEach((r: any, i: number) => {
const x = r.x * _kcTestCanvas.width;
const y = r.y * _kcTestCanvas.height;
const w = r.width * _kcTestCanvas.width;
const h = r.height * _kcTestCanvas.height;
const borderColor = BORDER_COLORS[i % BORDER_COLORS.length];
ctx.fillStyle = r.color.hex + '33';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = borderColor;
ctx.lineWidth = 3;
ctx.strokeRect(x, y, w, h);
ctx.fillStyle = '#fff';
ctx.font = 'bold 14px sans-serif';
ctx.shadowColor = '#000';
ctx.shadowBlur = 3;
ctx.fillText(r.name, x + 4, y + 18);
ctx.shadowBlur = 0;
ctx.fillStyle = r.color.hex;
ctx.fillRect(x + w - 24, y + 2, 22, 22);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.strokeRect(x + w - 24, y + 2, 22, 22);
});
// Update lightbox image directly (use data URL for full-size display)
const lbImg = document.getElementById('lightbox-image') as HTMLImageElement;
if (lbImg) {
lbImg.src = _kcTestCanvas.toDataURL('image/jpeg', 0.9);
lbImg.style.display = '';
lbImg.style.maxWidth = '100%';
lbImg.style.width = '100%';
}
// Hide spinner after first frame
const spinner = document.querySelector('#image-lightbox .lightbox-spinner') as HTMLElement | null;
if (spinner) spinner.style.display = 'none';
// Update swatches
const statsEl = document.getElementById('lightbox-stats')!;
const swatches = rects.map((r: any) =>
`<div style="display:inline-flex;align-items:center;gap:6px;margin:4px 8px;">
<span style="display:inline-block;width:20px;height:20px;background:${r.color.hex};border:1px solid #888;border-radius:3px;"></span>
<span>${escapeHtml(r.name)}</span>
<small style="opacity:0.6;">${r.color.hex}</small>
</div>`
).join('');
statsEl.innerHTML = `
<div style="display:flex;flex-wrap:wrap;justify-content:center;">${swatches}</div>
<div style="margin-top:4px;opacity:0.6;text-align:center;">Mode: ${mode} | ${rects.length} region${rects.length !== 1 ? 's' : ''}</div>
`;
statsEl.style.display = '';
};
tmpImg.src = data.image;
}
export async function testCSPT(templateId: string) {
_cssTestCSPTMode = true;
_cssTestCSPTId = templateId;
@@ -13,7 +13,7 @@ import {
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST,
ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER,
ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER, ICON_TRASH, ICON_PATTERN_TEMPLATE,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
@@ -58,6 +58,8 @@ class CSSEditorModal extends Modal {
onForceClose() {
if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; }
if (_kcPictureSourceEntitySelect) { _kcPictureSourceEntitySelect.destroy(); _kcPictureSourceEntitySelect = null; }
if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; }
compositeDestroyEntitySelects();
}
@@ -110,6 +112,7 @@ class CSSEditorModal extends Modal {
candlelight_speed: (document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value,
processed_input: (document.getElementById('css-editor-processed-input') as HTMLInputElement).value,
processed_template: (document.getElementById('css-editor-processed-template') as HTMLInputElement).value,
kc_rects: JSON.stringify(_kcEditorRects),
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
};
}
@@ -125,13 +128,79 @@ let _cssAudioSourceEntitySelect: any = null;
let _cssClockEntitySelect: any = null;
let _processedInputEntitySelect: any = null;
let _processedTemplateEntitySelect: any = null;
let _kcPictureSourceEntitySelect: any = null;
let _kcInterpolationIconSelect: any = null;
// ── Key Colors rectangle editor state ──
let _kcEditorRects: Array<{ name: string; x: number; y: number; width: number; height: number }> = [];
function _renderKCRectSummary(): void {
const el = document.getElementById('css-editor-kc-rect-summary');
if (!el) return;
if (_kcEditorRects.length === 0) {
el.textContent = t('color_strip.key_colors.no_rects');
} else {
const names = _kcEditorRects.map(r => r.name).join(', ');
el.textContent = `${_kcEditorRects.length} region${_kcEditorRects.length !== 1 ? 's' : ''}: ${names}`;
}
}
function _openKCRegionEditor(): void {
// Open the pattern template canvas editor in inline mode
const { showPatternTemplateEditor } = window as any;
if (!showPatternTemplateEditor) return;
showPatternTemplateEditor(null, null, {
rects: _kcEditorRects.map(r => ({ ...r })),
onSave: (rects: any[]) => {
_kcEditorRects = rects;
_renderKCRectSummary();
},
});
}
(window as any)._openKCRegionEditor = _openKCRegionEditor;
async function configureKCRegions(sourceId: string): Promise<void> {
// Fetch source to get current rectangles
try {
const resp = await fetchWithAuth(`/color-strip-sources/${sourceId}`);
if (!resp.ok) throw new Error('Failed to load source');
const source = await resp.json();
const rects = source.rectangles || [];
const { showPatternTemplateEditor } = window as any;
if (!showPatternTemplateEditor) return;
showPatternTemplateEditor(null, null, {
rects: rects.map((r: any) => ({ ...r })),
onSave: async (newRects: any[]) => {
// Save rectangles back to the CSS source
try {
const putResp = await fetchWithAuth(`/color-strip-sources/${sourceId}`, {
method: 'PUT',
body: JSON.stringify({ rectangles: newRects }),
});
if (!putResp.ok) throw new Error('Failed to save');
showToast(t('color_strip.updated'), 'success');
colorStripSourcesCache.invalidate();
if (window.loadPictureSources) await window.loadPictureSources();
} catch (e: any) {
showToast(e.message, 'error');
}
},
});
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
(window as any).configureKCRegions = configureKCRegions;
/* ── Icon-grid type selector ──────────────────────────────────── */
const CSS_TYPE_KEYS = [
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
'effect', 'composite', 'mapped', 'audio',
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed',
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
];
function _buildCSSTypeItems() {
@@ -178,6 +247,7 @@ const CSS_SECTION_MAP: Record<string, string> = {
'candlelight': 'css-editor-candlelight-section',
'weather': 'css-editor-weather-section',
'processed': 'css-editor-processed-section',
'key_colors': 'css-editor-key-colors-section',
};
const CSS_ALL_SECTION_IDS = [...new Set(Object.values(CSS_SECTION_MAP))];
@@ -554,7 +624,7 @@ function _renderCustomPresetList() {
title="${t('color_strip.gradient.preset.apply')}">&#x2713;</button>
<button type="button" class="btn btn-icon btn-sm btn-danger"
onclick="deleteAndRefreshGradientPreset('${g.id}')"
title="${t('common.delete')}">&#x2715;</button>
title="${t('common.delete')}">${ICON_TRASH}</button>
</div>`;
}).join('');
}
@@ -710,7 +780,7 @@ function _colorCycleRenderList() {
<input type="color" value="${hex}">
${canRemove
? `<button type="button" class="btn btn-secondary color-cycle-remove-btn"
onclick="colorCycleRemoveColor(${i})">&#x2715;</button>`
onclick="colorCycleRemoveColor(${i})">${ICON_TRASH}</button>`
: `<div style="height:14px"></div>`}
</div>
`).join('') + `<div class="color-cycle-item"><button type="button" class="btn btn-secondary color-cycle-add-btn" onclick="colorCycleAddColor()">+</button></div>`;
@@ -778,7 +848,7 @@ function _mappedRenderList() {
<div class="segment-row-header">
<span class="segment-index-label">#${i + 1}</span>
<button type="button" class="btn-icon-inline btn-danger-text"
onclick="mappedRemoveZone(${i})" title="${t('common.delete')}">&times;</button>
onclick="mappedRemoveZone(${i})" title="${t('common.delete')}">${ICON_TRASH}</button>
</div>
<div class="segment-row-fields">
<select class="mapped-zone-source" data-idx="${i}">${srcOptions}</select>
@@ -969,7 +1039,7 @@ type CardPropsRenderer = (source: ColorStripSource, opts: {
const NON_PICTURE_TYPES = new Set([
'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped',
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed',
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
]);
const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
@@ -1115,6 +1185,20 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
${clockBadge}
`;
},
key_colors: (source, { pictureSourceMap }) => {
const rectCount = (source.rectangles || []).length;
const mode = source.interpolation_mode || 'average';
const ps = pictureSourceMap && source.picture_source_id ? pictureSourceMap[source.picture_source_id] : null;
const psName = ps?.name || '—';
const psLink = ps
? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','raw','raw-streams','data-stream-id','${source.picture_source_id}')`
: '';
return `
<span class="stream-card-prop${psLink}" title="${t('color_strip.key_colors.picture_source')}">${ICON_LINK_SOURCE} ${escapeHtml(psName)}</span>
<span class="stream-card-prop">${ICON_PALETTE} ${rectCount} region${rectCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop">${mode}</span>
`;
},
processed: (source) => {
const inputSrc = ((colorStripSourcesCache.data || []) as any[]).find(s => s.id === source.input_source_id);
const inputName = inputSrc?.name || source.input_source_id || '—';
@@ -1195,6 +1279,10 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
const notifHistoryBtn = isNotification
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
: '';
const isKeyColors = source.source_type === 'key_colors';
const regionsBtn = isKeyColors
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); configureKCRegions('${source.id}')" title="${t('color_strip.key_colors.configure_regions')}">${ICON_PATTERN_TEMPLATE}</button>`
: '';
const testPreviewBtn = `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`;
return wrapCard({
@@ -1215,7 +1303,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
actions: `
<button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
${calibrationBtn}${overlayBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`,
${calibrationBtn}${overlayBtn}${regionsBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`,
});
}
@@ -1658,6 +1746,97 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
};
},
},
key_colors: {
async load(css) {
// Populate and wire picture source EntitySelect
const sourceSelect = document.getElementById('css-editor-kc-picture-source') as HTMLSelectElement;
const sources = await streamsCache.fetch().catch((): any[] => []);
sourceSelect.innerHTML = sources.map((s: any) =>
`<option value="${s.id}" ${s.id === (css.picture_source_id || '') ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
if (_kcPictureSourceEntitySelect) _kcPictureSourceEntitySelect.destroy();
_kcPictureSourceEntitySelect = new EntitySelect({
target: sourceSelect,
getItems: () => sources.map((s: any) => ({ value: s.id, label: s.name, icon: getPictureSourceIcon(s.stream_type), desc: s.stream_type })),
placeholder: t('palette.search'),
});
// Wire interpolation mode IconSelect
const interpSelect = document.getElementById('css-editor-kc-interpolation') as HTMLSelectElement;
interpSelect.value = css.interpolation_mode || 'average';
if (_kcInterpolationIconSelect) _kcInterpolationIconSelect.destroy();
_kcInterpolationIconSelect = new IconSelect({
target: interpSelect,
items: [
{ value: 'average', icon: `<svg class="icon" viewBox="0 0 24 24">${P.palette}</svg>`, label: t('color_strip.key_colors.mode.average'), desc: t('color_strip.key_colors.mode.average.desc') },
{ value: 'median', icon: `<svg class="icon" viewBox="0 0 24 24">${P.palette}</svg>`, label: t('color_strip.key_colors.mode.median'), desc: t('color_strip.key_colors.mode.median.desc') },
{ value: 'dominant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.palette}</svg>`, label: t('color_strip.key_colors.mode.dominant'), desc: t('color_strip.key_colors.mode.dominant.desc') },
],
columns: 1,
});
const smoothing = css.smoothing ?? 0.3;
(document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = smoothing;
(document.getElementById('css-editor-kc-smoothing-val') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
(document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value = css.brightness ?? 1.0;
(document.getElementById('css-editor-kc-brightness-val') as HTMLElement).textContent = parseFloat(css.brightness ?? 1.0).toFixed(2);
// Load rectangles
_kcEditorRects = (css.rectangles || []).map((r: any) => ({ ...r }));
_renderKCRectSummary();
},
async reset() {
const sourceSelect = document.getElementById('css-editor-kc-picture-source') as HTMLSelectElement;
const sources = await streamsCache.fetch().catch((): any[] => []);
sourceSelect.innerHTML = sources.map((s: any) =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
if (_kcPictureSourceEntitySelect) _kcPictureSourceEntitySelect.destroy();
_kcPictureSourceEntitySelect = new EntitySelect({
target: sourceSelect,
getItems: () => sources.map((s: any) => ({ value: s.id, label: s.name, icon: getPictureSourceIcon(s.stream_type), desc: s.stream_type })),
placeholder: t('palette.search'),
});
const interpSelect = document.getElementById('css-editor-kc-interpolation') as HTMLSelectElement;
interpSelect.value = 'average';
if (_kcInterpolationIconSelect) _kcInterpolationIconSelect.destroy();
_kcInterpolationIconSelect = new IconSelect({
target: interpSelect,
items: [
{ value: 'average', icon: `<svg class="icon" viewBox="0 0 24 24">${P.palette}</svg>`, label: t('color_strip.key_colors.mode.average'), desc: t('color_strip.key_colors.mode.average.desc') },
{ value: 'median', icon: `<svg class="icon" viewBox="0 0 24 24">${P.palette}</svg>`, label: t('color_strip.key_colors.mode.median'), desc: t('color_strip.key_colors.mode.median.desc') },
{ value: 'dominant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.palette}</svg>`, label: t('color_strip.key_colors.mode.dominant'), desc: t('color_strip.key_colors.mode.dominant.desc') },
],
columns: 1,
});
(document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = 0.3 as any;
(document.getElementById('css-editor-kc-smoothing-val') as HTMLElement).textContent = '0.30';
(document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-kc-brightness-val') as HTMLElement).textContent = '1.00';
_kcEditorRects = [{ name: 'Region 1', x: 0.0, y: 0.0, width: 1.0, height: 1.0 }];
_renderKCRectSummary();
},
getPayload(name) {
const psId = (document.getElementById('css-editor-kc-picture-source') as HTMLSelectElement).value;
if (!psId) {
cssEditorModal.showError(t('color_strip.key_colors.error.no_source'));
return null;
}
if (_kcEditorRects.length === 0) {
cssEditorModal.showError(t('color_strip.key_colors.error.no_rects'));
return null;
}
return {
name,
picture_source_id: psId,
rectangles: _kcEditorRects.map(r => ({ name: r.name, x: r.x, y: r.y, width: r.width, height: r.height })),
interpolation_mode: (document.getElementById('css-editor-kc-interpolation') as HTMLSelectElement).value,
smoothing: parseFloat((document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value),
brightness: parseFloat((document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value),
};
},
},
};
/* ── Editor open/close ────────────────────────────────────────── */
@@ -6,6 +6,7 @@
*/
import { t } from '../core/i18n.ts';
import { ICON_TRASH } from '../core/icons.ts';
/* ── Types ─────────────────────────────────────────────────────── */
@@ -304,7 +305,7 @@ function _gradientRenderStopList(): void {
style="display:${hasBidir ? 'inline-block' : 'none'}" title="Right color">
<span class="gradient-stop-spacer"></span>
<button type="button" class="btn btn-sm btn-danger gradient-stop-remove-btn"
title="Remove stop"${_gradientStops.length <= 2 ? ' disabled' : ''}></button>
title="Remove stop"${_gradientStops.length <= 2 ? ' disabled' : ''}>${ICON_TRASH}</button>
`;
// Select row on mousedown — CSS-only update so child click events are not interrupted
@@ -2,17 +2,16 @@
* HA Light Targets — editor, cards, CRUD for Home Assistant light output targets.
*/
import { _cachedHASources, haSourcesCache, colorStripSourcesCache, outputTargetsCache } from '../core/state.ts';
import { _cachedHASources, _cachedValueSources, haSourcesCache, colorStripSourcesCache, outputTargetsCache, valueSourcesCache } from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH } from '../core/icons.ts';
import { showToast, showConfirm, formatUptime } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, getColorStripIcon, getValueSourceIcon } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { getColorStripIcon } from '../core/icons.ts';
const ICON_HA = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -22,6 +21,7 @@ const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
let _haLightTagsInput: TagInput | null = null;
let _haSourceEntitySelect: EntitySelect | null = null;
let _cssSourceEntitySelect: EntitySelect | null = null;
let _brightnessVsEntitySelect: EntitySelect | null = null;
let _mappingEntitySelects: EntitySelect[] = [];
let _editorCssSources: any[] = [];
let _cachedHAEntities: any[] = []; // fetched from selected HA source
@@ -33,6 +33,7 @@ class HALightEditorModal extends Modal {
if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; }
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
if (_brightnessVsEntitySelect) { _brightnessVsEntitySelect.destroy(); _brightnessVsEntitySelect = null; }
_destroyMappingEntitySelects();
}
@@ -207,6 +208,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
const [haSources, cssSources] = await Promise.all([
haSourcesCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch().catch(() => {}),
]);
_editorCssSources = cssSources;
@@ -307,6 +309,23 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
placeholder: t('palette.search'),
});
// Brightness value source
const bvsSelect = document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement;
bvsSelect.innerHTML = `<option value="">${t('targets.brightness_vs.none')}</option>` +
_cachedValueSources.map((vs: any) =>
`<option value="${vs.id}" ${vs.id === (editData?.brightness_value_source_id || '') ? 'selected' : ''}>${escapeHtml(vs.name)}</option>`
).join('');
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
_brightnessVsEntitySelect = new EntitySelect({
target: bvsSelect,
getItems: () => _cachedValueSources.map((vs: any) => ({
value: vs.id, label: vs.name, icon: getValueSourceIcon(vs.source_type), desc: vs.source_type,
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('targets.brightness_vs.none'),
});
// Tags
if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; }
_haLightTagsInput = new TagInput(document.getElementById('ha-light-tags-container'), { placeholder: t('tags.placeholder') });
@@ -343,10 +362,13 @@ export async function saveHALightEditor(): Promise<void> {
// Collect mappings
const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.entity_id);
const brightnessVsId = (document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement).value;
const payload: any = {
name,
ha_source_id: haSourceId,
color_strip_source_id: cssSourceId,
brightness_value_source_id: brightnessVsId,
ha_light_mappings: mappings,
update_rate: updateRate,
transition,
@@ -407,13 +429,28 @@ export async function cloneHALightTarget(targetId: string): Promise<void> {
// ── Card rendering ──
export function createHALightTargetCard(target: any, haSourceMap: Record<string, any> = {}, cssSourceMap: Record<string, any> = {}): string {
export function createHALightTargetCard(target: any, haSourceMap: Record<string, any> = {}, cssSourceMap: Record<string, any> = {}, valueSourceMap: Record<string, any> = {}): string {
const haSource = haSourceMap[target.ha_source_id];
const cssSource = cssSourceMap[target.color_strip_source_id];
const haName = haSource ? escapeHtml(haSource.name) : target.ha_source_id || '—';
const cssName = cssSource ? escapeHtml(cssSource.name) : target.color_strip_source_id || '—';
const cssId = target.color_strip_source_id;
const cssName = cssSource ? escapeHtml(cssSource.name) : cssId || '—';
const mappingCount = target.ha_light_mappings?.length || 0;
const isRunning = target.state?.processing;
const state = target.state || {};
const metrics = target.metrics || {};
// Crosslinks
const haLink = haSource
? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','home_assistant','ha-sources','data-id','${target.ha_source_id}')`
: '';
const cssLink = cssSource
? ` stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')`
: '';
// Brightness value source
const bvsId = target.brightness_value_source_id || '';
const bvs = bvsId && valueSourceMap[bvsId] ? valueSourceMap[bvsId] : null;
return wrapCard({
type: 'card',
@@ -426,13 +463,32 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
<span class="card-title-text">${ICON_HA} ${escapeHtml(target.name)}</span>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">${ICON_HA} ${haName}</span>
${cssName !== '—' ? `<span class="stream-card-prop">${_icon(P.palette)} ${cssName}</span>` : ''}
<span class="stream-card-prop${haLink}" title="HA Connection">${ICON_HA} ${haName}</span>
${cssName !== '—' ? `<span class="stream-card-prop${cssLink}" title="${t('targets.color_strip_source')}">${cssSource ? getColorStripIcon(cssSource.source_type) : _icon(P.palette)} ${cssName}</span>` : ''}
<span class="stream-card-prop">${_icon(P.listChecks)} ${mappingCount} light${mappingCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop">${_icon(P.clock)} ${target.update_rate ?? 2.0} Hz</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
</div>
${renderTagChips(target.tags || [])}
${target.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(target.description)}</div>` : ''}`,
${target.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(target.description)}</div>` : ''}
<div class="card-content">
${isRunning ? `
<div class="metrics-grid target-metrics-expanded">
<div class="metric">
<div class="metric-label">${t('targets.fps')}</div>
<div class="metric-value" data-tm="fps">${(state.fps_actual ?? target.update_rate ?? 2).toFixed(1)} Hz</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.uptime')}</div>
<div class="metric-value" data-tm="uptime">${metrics.uptime_seconds ? formatUptime(metrics.uptime_seconds) : '---'}</div>
</div>
<div class="metric">
<div class="metric-label">HA</div>
<div class="metric-value" data-tm="ha-status">${state.ha_connected ? ICON_OK : ICON_WARNING}</div>
</div>
</div>
` : ''}
</div>`,
actions: `
<button class="btn btn-icon ${isRunning ? 'btn-danger' : 'btn-primary'}" data-action="${isRunning ? 'stop' : 'start'}" title="${isRunning ? t('targets.stop') : t('targets.start')}">
${isRunning ? ICON_STOP : ICON_START}
@@ -442,6 +498,24 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
});
}
// ── Metrics patching ──
export function patchHALightTargetMetrics(target: any): void {
const card = document.querySelector(`[data-ha-target-id="${target.id}"]`);
if (!card) return;
const state = target.state || {};
const metrics = target.metrics || {};
const fpsEl = card.querySelector('[data-tm="fps"]') as HTMLElement | null;
if (fpsEl) fpsEl.textContent = `${(state.fps_actual ?? 0).toFixed(1)} Hz`;
const uptimeEl = card.querySelector('[data-tm="uptime"]') as HTMLElement | null;
if (uptimeEl) uptimeEl.textContent = metrics.uptime_seconds ? formatUptime(metrics.uptime_seconds) : '---';
const haEl = card.querySelector('[data-tm="ha-status"]') as HTMLElement | null;
if (haEl) haEl.innerHTML = state.ha_connected ? ICON_OK : ICON_WARNING;
}
// ── Event delegation ──
const _haLightActions: Record<string, (id: string) => void> = {
@@ -20,7 +20,7 @@ import { patternTemplatesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.ts';
import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TRASH } from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { EntitySelect } from '../core/entity-palette.ts';
@@ -29,6 +29,9 @@ import type { PatternTemplate } from '../types.ts';
let _patternBgEntitySelect: EntitySelect | null = null;
let _patternTagsInput: TagInput | null = null;
// When set, save returns rectangles to this callback instead of saving to API
let _inlineCallback: ((rects: any[]) => void) | null = null;
// ── Auto-name ──
let _ptNameManuallyEdited = false;
@@ -98,7 +101,8 @@ export function createPatternTemplateCard(pt: PatternTemplate) {
});
}
export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null): Promise<void> {
export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null, opts?: { rects?: any[]; onSave?: (rects: any[]) => void }): Promise<void> {
_inlineCallback = opts?.onSave || null;
try {
// Load sources for background capture
const sources = await streamsCache.fetch().catch((): any[] => []);
@@ -150,6 +154,13 @@ export async function showPatternTemplateEditor(templateId: string | null = null
(document.getElementById('pattern-template-modal-title') as HTMLElement).innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.add')}`;
setPatternEditorRects((cloneData.rectangles || []).map(r => ({ ...r })));
_editorTags = cloneData.tags || [];
} else if (_inlineCallback && opts?.rects) {
// Inline mode: editing rectangles for a CSS source (no name/description)
(document.getElementById('pattern-template-id') as HTMLInputElement).value = '';
(document.getElementById('pattern-template-name') as HTMLInputElement).value = 'Regions';
(document.getElementById('pattern-template-description') as HTMLInputElement).value = '';
(document.getElementById('pattern-template-modal-title') as HTMLElement).innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('color_strip.key_colors.configure_regions')}`;
setPatternEditorRects((opts.rects || []).map((r: any) => ({ ...r })));
} else {
(document.getElementById('pattern-template-id') as HTMLInputElement).value = '';
(document.getElementById('pattern-template-name') as HTMLInputElement).value = '';
@@ -158,15 +169,25 @@ export async function showPatternTemplateEditor(templateId: string | null = null
setPatternEditorRects([]);
}
// Hide name/description/tags fields in inline mode
const nameGroup = document.getElementById('pattern-name-group');
const descGroup = document.getElementById('pattern-desc-group');
const tagsContainer = document.getElementById('pattern-tags-container');
if (nameGroup) nameGroup.style.display = _inlineCallback ? 'none' : '';
if (descGroup) descGroup.style.display = _inlineCallback ? 'none' : '';
if (tagsContainer) tagsContainer.style.display = _inlineCallback ? 'none' : '';
// Tags
if (_patternTagsInput) { _patternTagsInput.destroy(); _patternTagsInput = null; }
_patternTagsInput = new TagInput(document.getElementById('pattern-tags-container'), { placeholder: t('tags.placeholder') });
_patternTagsInput.setValue(_editorTags);
if (!_inlineCallback) {
_patternTagsInput = new TagInput(document.getElementById('pattern-tags-container'), { placeholder: t('tags.placeholder') });
_patternTagsInput.setValue(_editorTags);
}
// Auto-name wiring
_ptNameManuallyEdited = !!(templateId || cloneData);
_ptNameManuallyEdited = !!(templateId || cloneData || _inlineCallback);
(document.getElementById('pattern-template-name') as HTMLElement).oninput = () => { _ptNameManuallyEdited = true; };
if (!templateId && !cloneData) _autoGeneratePatternName();
if (!templateId && !cloneData && !_inlineCallback) _autoGeneratePatternName();
patternModal.snapshot();
@@ -197,6 +218,18 @@ export function forceClosePatternTemplateModal(): void {
}
export async function savePatternTemplate(): Promise<void> {
// Inline mode: return rectangles to callback, don't save to API
if (_inlineCallback) {
const rects = patternEditorRects.map(r => ({
name: r.name, x: r.x, y: r.y, width: r.width, height: r.height,
}));
const cb = _inlineCallback;
_inlineCallback = null;
patternModal.forceClose();
cb(rects);
return;
}
const templateId = (document.getElementById('pattern-template-id') as HTMLInputElement).value;
const name = (document.getElementById('pattern-template-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('pattern-template-description') as HTMLInputElement).value.trim();
@@ -296,7 +329,7 @@ export function renderPatternRectList(): void {
<input type="number" value="${rect.y.toFixed(2)}" min="0" max="1" step="0.01" onchange="updatePatternRect(${i}, 'y', parseFloat(this.value)||0)" onclick="event.stopPropagation()">
<input type="number" value="${rect.width.toFixed(2)}" min="0.01" max="1" step="0.01" onchange="updatePatternRect(${i}, 'width', parseFloat(this.value)||0.01)" onclick="event.stopPropagation()">
<input type="number" value="${rect.height.toFixed(2)}" min="0.01" max="1" step="0.01" onchange="updatePatternRect(${i}, 'height', parseFloat(this.value)||0.01)" onclick="event.stopPropagation()">
<button type="button" class="pattern-rect-remove-btn" onclick="event.stopPropagation(); removePatternRect(${i})" title="${t('pattern.rect.remove')}">&#x2715;</button>
<button type="button" class="pattern-rect-remove-btn" onclick="event.stopPropagation(); removePatternRect(${i})" title="${t('pattern.rect.remove')}">${ICON_TRASH}</button>
</div>
`).join('');
}
@@ -90,7 +90,7 @@ export function createSceneCard(preset: ScenePreset) {
const colorStyle = cardColorStyle(preset.id);
return `<div class="card" data-scene-id="${preset.id}"${colorStyle ? ` style="${colorStyle}"` : ''}>
<div class="card-top-actions">
<button class="card-remove-btn" data-action="delete-scene" data-id="${preset.id}" title="${t('scenes.delete')}">&#x2715;</button>
<button class="card-remove-btn" data-action="delete-scene" data-id="${preset.id}" title="${t('scenes.delete')}">${ICON_TRASH}</button>
</div>
<div class="card-header">
<div class="card-title" title="${escapeHtml(preset.name)}"><span class="card-title-text">${escapeHtml(preset.name)}</span></div>
@@ -219,7 +219,7 @@ export async function editScenePreset(presetId: string): Promise<void> {
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" data-action="remove-scene-target" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">${ICON_TRASH}</button>`;
targetList.appendChild(item);
}
_refreshTargetSelect();
@@ -314,7 +314,7 @@ function _addTargetToList(targetId: string, targetName: string): void {
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = targetId;
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">${ICON_TRASH}</button>`;
list.appendChild(item);
_refreshTargetSelect();
}
@@ -433,7 +433,7 @@ export async function cloneScenePreset(presetId: string): Promise<void> {
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" data-action="remove-scene-target" title="Remove">&#x2715;</button>`;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" data-action="remove-scene-target" title="Remove">${ICON_TRASH}</button>`;
targetList.appendChild(item);
}
_refreshTargetSelect();
@@ -6,11 +6,10 @@ import {
apiKey,
_targetEditorDevices, set_targetEditorDevices,
_deviceBrightnessCache,
kcWebSockets,
ledPreviewWebSockets,
_cachedValueSources, valueSourcesCache,
streamsCache, audioSourcesCache, syncClocksCache,
colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
colorStripSourcesCache, devicesCache, outputTargetsCache,
_cachedHASources, haSourcesCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice, fetchMetricsHistory } from '../core/api.ts';
@@ -19,8 +18,7 @@ import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing,
import { Modal } from '../core/modal.ts';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.ts';
import { _splitOpenrgbZone } from './device-discovery.ts';
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.ts';
import { createHALightTargetCard, initHALightTargetDelegation } from './ha-light-targets.ts';
import { createHALightTargetCard, initHALightTargetDelegation, patchHALightTargetMetrics } from './ha-light-targets.ts';
import {
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
@@ -83,16 +81,6 @@ async function _bulkDeleteDevices(ids: any) {
await loadTargetsTab();
}
async function _bulkDeletePatternTemplates(ids: any) {
const results = await Promise.allSettled(ids.map(id =>
fetchWithAuth(`/pattern-templates/${id}`, { method: 'DELETE' })
));
const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t('targets.deleted'), 'success');
patternTemplatesCache.invalidate();
await loadTargetsTab();
}
const _targetBulkActions = [
{ key: 'start', labelKey: 'bulk.start', icon: ICON_START, handler: _bulkStartTargets },
@@ -105,11 +93,7 @@ const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.de
{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteDevices },
] });
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, bulkActions: _targetBulkActions });
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, bulkActions: _targetBulkActions });
const csHALightTargets = new CardSection('ha-light-targets', { titleKey: 'ha_light.section.title', gridClass: 'devices-grid', addCardOnclick: "showHALightEditor()", keyAttr: 'data-ha-target-id', emptyKey: 'section.empty.ha_light_targets', bulkActions: _targetBulkActions });
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates', bulkActions: [
{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeletePatternTemplates },
] });
// Re-render targets tab when language changes (only if tab is active)
document.addEventListener('languageChanged', () => {
@@ -582,8 +566,6 @@ export function switchTargetSubTab(tabKey: any) {
const _targetSectionMap = {
'led-devices': [csDevices],
'led-targets': [csLedTargets],
'kc-targets': [csKCTargets],
'kc-patterns': [csPatternTemplates],
};
let _loadTargetsLock = false;
@@ -599,11 +581,10 @@ export async function loadTargetsTab() {
try {
// Fetch all entities via DataCache
const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([
const [devices, targets, cssArr, psArr, valueSrcArr, asSrcArr] = await Promise.all([
devicesCache.fetch().catch((): any[] => []),
outputTargetsCache.fetch().catch((): any[] => []),
colorStripSourcesCache.fetch().catch((): any[] => []),
patternTemplatesCache.fetch().catch((): any[] => []),
streamsCache.fetch().catch((): any[] => []),
valueSourcesCache.fetch().catch((): any[] => []),
audioSourcesCache.fetch().catch((): any[] => []),
@@ -617,9 +598,6 @@ export async function loadTargetsTab() {
let pictureSourceMap = {};
psArr.forEach(s => { pictureSourceMap[s.id] = s; });
let patternTemplateMap = {};
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
let valueSourceMap = {};
valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; });
@@ -661,7 +639,6 @@ export async function loadTargetsTab() {
// Group by type
const ledDevices = devicesWithState;
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
const haLightTargets = targetsWithState.filter(t => t.target_type === 'ha_light');
// Update tab badge with running target count
@@ -679,13 +656,6 @@ export async function loadTargetsTab() {
{ key: 'led-targets', titleKey: 'targets.section.targets', icon: getTargetTypeIcon('led'), count: ledTargets.length },
]
},
{
key: 'kc_group', icon: getTargetTypeIcon('key_colors'), titleKey: 'targets.subtab.key_colors',
children: [
{ key: 'kc-targets', titleKey: 'targets.section.key_colors', icon: getTargetTypeIcon('key_colors'), count: kcTargets.length },
{ key: 'kc-patterns', titleKey: 'targets.section.pattern_templates', icon: ICON_TEMPLATE, count: patternTemplates.length },
]
},
{
key: 'ha_light_group', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'ha_light.section.title',
children: [
@@ -694,21 +664,15 @@ export async function loadTargetsTab() {
}
];
// Determine which tree leaf is active — migrate old values
const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns', 'ha-light-targets'];
const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab
: activeSubTab === 'key_colors' ? 'kc-targets' : 'led-devices';
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
const validLeaves = ['led-devices', 'led-targets', 'ha-light-targets'];
const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab : 'led-devices';
// Build items arrays for each section (apply saved drag order)
const deviceItems = csDevices.applySortOrder(ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) })));
const ledTargetItems = csLedTargets.applySortOrder(ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) })));
const kcTargetItems = csKCTargets.applySortOrder(kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) })));
const haSourceMap: Record<string, any> = {};
_cachedHASources.forEach(s => { haSourceMap[s.id] = s; });
const haLightTargetItems = csHALightTargets.applySortOrder(haLightTargets.map(t => ({ key: t.id, html: createHALightTargetCard(t, haSourceMap, colorStripSourceMap) })));
const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
const haLightTargetItems = csHALightTargets.applySortOrder(haLightTargets.map(t => ({ key: t.id, html: createHALightTargetCard(t, haSourceMap, colorStripSourceMap, valueSourceMap) })));
// Track which target cards were replaced/added (need chart re-init)
let changedTargetIds: Set<string> | null = null;
@@ -718,17 +682,12 @@ export async function loadTargetsTab() {
_targetsTree.updateCounts({
'led-devices': ledDevices.length,
'led-targets': ledTargets.length,
'kc-targets': kcTargets.length,
'kc-patterns': patternTemplates.length,
'ha-light-targets': haLightTargets.length,
});
csDevices.reconcile(deviceItems);
const ledResult = csLedTargets.reconcile(ledTargetItems);
const kcResult = csKCTargets.reconcile(kcTargetItems);
csPatternTemplates.reconcile(patternItems);
csHALightTargets.reconcile(haLightTargetItems);
changedTargetIds = new Set<string>([...(ledResult.added as unknown as string[]), ...(ledResult.replaced as unknown as string[]), ...(ledResult.removed as unknown as string[]),
...(kcResult.added as unknown as string[]), ...(kcResult.replaced as unknown as string[]), ...(kcResult.removed as unknown as string[])]);
changedTargetIds = new Set<string>([...(ledResult.added as unknown as string[]), ...(ledResult.replaced as unknown as string[]), ...(ledResult.removed as unknown as string[])]);
// Restore LED preview state on replaced cards (panel hidden by default in HTML)
for (const id of Array.from(ledResult.replaced) as any[]) {
@@ -741,12 +700,10 @@ export async function loadTargetsTab() {
const panels = [
{ key: 'led-devices', html: csDevices.render(deviceItems) },
{ key: 'led-targets', html: csLedTargets.render(ledTargetItems) },
{ key: 'kc-targets', html: csKCTargets.render(kcTargetItems) },
{ key: 'kc-patterns', html: csPatternTemplates.render(patternItems) },
{ key: 'ha-light-targets', html: csHALightTargets.render(haLightTargetItems) },
].map(p => `<div class="target-sub-tab-panel stream-tab-panel${p.key === activeLeaf ? ' active' : ''}" id="target-sub-tab-${p.key}">${p.html}</div>`).join('');
container.innerHTML = panels;
CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates, csHALightTargets]);
CardSection.bindAll([csDevices, csLedTargets, csHALightTargets]);
initHALightTargetDelegation(container);
// Render tree sidebar with expand/collapse buttons
@@ -757,18 +714,15 @@ export async function loadTargetsTab() {
// Show/hide stop-all buttons based on running state
const ledRunning = ledTargets.some(t => t.state && t.state.processing);
const kcRunning = kcTargets.some(t => t.state && t.state.processing);
const ledStopBtn = container.querySelector('[data-stop-all="led"]') as HTMLElement | null;
const kcStopBtn = container.querySelector('[data-stop-all="kc"]') as HTMLElement | null;
if (ledStopBtn) { ledStopBtn.style.display = ledRunning ? '' : 'none'; if (!ledStopBtn.title) { ledStopBtn.title = t('targets.stop_all.button'); ledStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } }
if (kcStopBtn) { kcStopBtn.style.display = kcRunning ? '' : 'none'; if (!kcStopBtn.title) { kcStopBtn.title = t('targets.stop_all.button'); kcStopBtn.setAttribute('aria-label', t('targets.stop_all.button')); } }
// Patch volatile metrics in-place (avoids full card replacement on polls)
for (const tgt of ledTargets) {
if (tgt.state && tgt.state.processing) _patchTargetMetrics(tgt);
}
for (const tgt of kcTargets) {
if (tgt.state && tgt.state.processing) patchKCTargetMetrics(tgt);
for (const tgt of haLightTargets) {
if (tgt.state && tgt.state.processing) patchHALightTargetMetrics(tgt);
}
// Attach event listeners and fetch brightness for device cards
@@ -806,20 +760,6 @@ export async function loadTargetsTab() {
}
}
// Manage KC WebSockets: connect for processing, disconnect for stopped
const processingKCIds = new Set();
kcTargets.forEach(target => {
if (target.state && target.state.processing) {
processingKCIds.add(target.id);
if (!kcWebSockets[target.id]) {
connectKCWebSocket(target.id);
}
}
});
Object.keys(kcWebSockets).forEach(id => {
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
});
// Auto-disconnect LED preview WebSockets for targets that stopped
const processingLedIds = new Set();
ledTargets.forEach(target => {
@@ -847,7 +787,7 @@ export async function loadTargetsTab() {
}
// Push FPS samples and create/update charts for running targets
const allTargets = [...ledTargets, ...kcTargets];
const allTargets = [...ledTargets];
const runningIds = new Set();
const runningTargetIds = allTargets.filter(t => t.state?.processing).map(t => t.id);
@@ -1168,12 +1108,6 @@ export async function stopAllLedTargets() {
await _stopAllByType('led');
}
export async function stopAllKCTargets() {
const confirmed = await showConfirm(t('confirm.stop_all'));
if (!confirmed) return;
await _stopAllByType('key_colors');
}
async function _stopAllByType(targetType: any) {
try {
const [allTargets, statesResp] = await Promise.all([
@@ -19,7 +19,7 @@ import {
getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon,
ICON_CLONE, ICON_EDIT, ICON_TEST,
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK,
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH,
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, ICON_TRASH,
} from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
@@ -897,7 +897,7 @@ export function addSchedulePoint(time: string = '', value: number = 1.0) {
<input type="range" class="schedule-value" min="0" max="1" step="0.01" value="${value}"
oninput="this.nextElementSibling.textContent = this.value">
<span class="schedule-value-display">${value}</span>
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="this.parentElement.remove()">&#x2715;</button>
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="this.parentElement.remove()">${ICON_TRASH}</button>
`;
list.appendChild(row);
_wireScheduleTimePicker(row);
@@ -87,7 +87,7 @@ export type CSSSourceType =
| 'picture' | 'picture_advanced' | 'static' | 'gradient'
| 'color_cycle' | 'effect' | 'composite' | 'mapped'
| 'audio' | 'api_input' | 'notification' | 'daylight'
| 'candlelight' | 'processed';
| 'candlelight' | 'processed' | 'weather' | 'key_colors';
export interface ColorStop {
position: number;
@@ -228,6 +228,11 @@ export interface ColorStripSource {
// Weather
weather_source_id?: string;
temperature_influence?: number;
// Key Colors
rectangles?: KeyColorRectangle[];
brightness?: number;
brightness_value_source_id?: string;
}
// ── Pattern Template ──────────────────────────────────────────
@@ -1167,6 +1167,23 @@
"color_strip.type.candlelight": "Candlelight",
"color_strip.type.candlelight.desc": "Realistic flickering candle simulation",
"color_strip.type.candlelight.hint": "Simulates realistic candle flickering across all LEDs with warm tones and organic flicker patterns.",
"color_strip.type.key_colors": "Key Colors",
"color_strip.type.key_colors.desc": "Extract colors from screen regions",
"color_strip.key_colors.picture_source": "Picture Source:",
"color_strip.key_colors.interpolation": "Color Mode:",
"color_strip.key_colors.smoothing": "Smoothing:",
"color_strip.key_colors.brightness": "Brightness:",
"color_strip.key_colors.rectangles": "Screen Regions:",
"color_strip.key_colors.no_rects": "No regions defined. Click Configure Regions to add.",
"color_strip.key_colors.configure_regions": "Configure Regions",
"color_strip.key_colors.mode.average": "Average",
"color_strip.key_colors.mode.average.desc": "Mean color of all pixels in the region",
"color_strip.key_colors.mode.median": "Median",
"color_strip.key_colors.mode.median.desc": "Median color (less affected by outliers)",
"color_strip.key_colors.mode.dominant": "Dominant",
"color_strip.key_colors.mode.dominant.desc": "Most frequent color (K-means clustering)",
"color_strip.key_colors.error.no_source": "Picture source is required",
"color_strip.key_colors.error.no_rects": "At least one screen region is required",
"color_strip.type.weather": "Weather",
"color_strip.type.weather.desc": "Weather-reactive ambient colors",
"color_strip.type.weather.hint": "Maps real-time weather conditions to ambient LED colors. Requires a Weather Source entity.",