refactor: key colors targets → CSS source type, HA target improvements
Lint & Test / test (push) Successful in 1m26s
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:
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}">✕</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' : ''}>▲</button>
|
||||
<button class="btn-micro" onclick="event.stopPropagation(); moveCalibrationLine(${i}, 1)" title="Move down" ${i === _state.lines.length - 1 ? 'disabled' : ''}>▼</button>
|
||||
<button class="btn-micro btn-danger" onclick="event.stopPropagation(); removeCalibrationLine(${i})" title="Remove">✕</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')}">✕</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})">✕</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')}">✓</button>
|
||||
<button type="button" class="btn btn-icon btn-sm btn-danger"
|
||||
onclick="deleteAndRefreshGradientPreset('${g.id}')"
|
||||
title="${t('common.delete')}">✕</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})">✕</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')}">×</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')}">✕</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')}">✕</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">✕</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">✕</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">✕</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()">✕</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.",
|
||||
|
||||
Reference in New Issue
Block a user