feat: new value source types (HA entity, gradient map, strip extract) + UI fixes
Lint & Test / test (push) Successful in 1m27s

New value source types:
- ha_entity: reads numeric values from HA entity state/attribute, normalizes
  via min/max range, applies EMA smoothing. EntitySelect for HA connection
  and entity selection with live entity list fetching.
- gradient_map: maps a float value source (0-1) through a gradient entity.
  EntitySelect for both input source and gradient with inline previews.
- css_extract: extracts single color by averaging LED range from a color
  strip source. EntitySelect for source selection.

Value source type picker:
- Filter tabs (All / Numeric / Color) above the icon grid
- showTypePicker extended with filterTabs + onFilterChange support

Palette selectors converted to EntitySelect:
- Effect palette, gradient preset, and audio palette selectors now use
  command-palette style EntitySelect with gradient strip previews

Tab indicator fixes:
- Icon now updates on tab switch (was passing no args to updateTabIndicator)
- Visible with any background effect active, not just Noise Field
- Noise Field is the default background effect for new users

Dashboard section collapse fix:
- Split header into clickable toggle (chevron+label) and non-clickable
  actions area — buttons no longer trigger collapse/expand

Discriminated union fix (422 errors):
- source_type/target_type now always included in update payloads for:
  CSS editor, LED target, HA light target, simple calibration,
  advanced calibration
This commit is contained in:
2026-03-29 20:38:22 +03:00
parent ea812bb4d5
commit 384362ccf1
61 changed files with 5367 additions and 1620 deletions
@@ -845,6 +845,32 @@ textarea:focus-visible {
pointer-events: none;
}
.type-picker-tabs {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.type-picker-tab {
flex: 1;
padding: 6px 12px;
border: 1px solid var(--border-color, #333);
border-radius: 6px;
background: var(--surface-2, #1e1e2e);
color: var(--text-secondary, #999);
font-size: 0.8rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.type-picker-tab:hover {
background: var(--surface-3, #2a2a3e);
color: var(--text-primary, #e0e0e0);
}
.type-picker-tab.active {
background: var(--primary-color, #63b3ed);
color: #fff;
border-color: var(--primary-color, #63b3ed);
}
/* ── Entity Palette (command-palette style selector) ─────── */
.entity-palette-overlay {
@@ -14,9 +14,16 @@
gap: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
user-select: none;
}
.dashboard-section-toggle {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
flex: 1;
min-width: 0;
}
.dashboard-section-chevron {
font-size: 0.6rem;
@@ -453,13 +460,12 @@
left: 12px;
font-size: 0.6rem;
font-weight: 400;
color: var(--text-muted);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: calc(100% - 4px);
pointer-events: none;
opacity: 0.7;
z-index: 1;
}
@@ -480,9 +486,12 @@
font-weight: 500;
color: var(--text-secondary);
background: var(--hover-bg);
padding: 1px 5px;
padding: 2px 5px;
border-radius: 3px;
letter-spacing: 0.2px;
vertical-align: middle;
display: inline-flex;
align-items: center;
}
.perf-chart-label .color-picker-swatch {
@@ -148,6 +148,8 @@ import {
editValueSource, cloneValueSource, deleteValueSource, onValueSourceTypeChange,
onDaylightVSRealTimeChange,
addSchedulePoint,
addAnimatedColor, removeAnimatedColor,
addColorSchedulePoint, removeColorSchedulePoint,
testValueSource, closeTestValueSourceModal,
} from './features/value-sources.ts';
@@ -468,6 +470,10 @@ Object.assign(window, {
onValueSourceTypeChange,
onDaylightVSRealTimeChange,
addSchedulePoint,
addAnimatedColor,
removeAnimatedColor,
addColorSchedulePoint,
removeColorSchedulePoint,
testValueSource,
closeTestValueSourceModal,
@@ -71,11 +71,14 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P
await new Promise(r => setTimeout(r, 500 * 2 ** attempt));
continue;
}
// Final attempt failed — show user-facing error
const errMsg = (err as Error)?.name === 'AbortError'
? t('api.error.timeout')
: t('api.error.network');
showToast(errMsg, 'error');
// Final attempt failed — show toast only if the connection
// overlay isn't already covering the screen
if (_serverOnline !== false) {
const errMsg = (err as Error)?.name === 'AbortError'
? t('api.error.timeout')
: t('api.error.network');
showToast(errMsg, 'error');
}
throw err;
}
}
@@ -0,0 +1,168 @@
/**
* BindableColorWidget — a color picker that can optionally bind to a color value source.
*
* Renders a color input with a small toggle button. When toggled to
* "bound" mode, shows an EntitySelect color value source picker.
* Emits a BindableColor value: plain [R,G,B] (static) or {color, source_id} (bound).
*/
import type { BindableColor } from '../types.ts';
import { bindableColor, bindableColorSourceId } from '../types.ts';
import { EntitySelect } from './entity-palette.ts';
import { getValueSourceIcon } from './icons.ts';
import { t } from './i18n.ts';
export interface BindableColorOpts {
container: HTMLElement;
default: number[];
/** Only show color value sources (return_type="color") */
valueSources: () => Array<{ id: string; name: string; source_type: string; return_type?: string }>;
onChange?: (value: BindableColor) => void;
idPrefix?: string;
noneLabel?: string;
}
let _widgetCounter = 0;
export class BindableColorWidget {
private _container: HTMLElement;
private _opts: BindableColorOpts;
private _id: string;
private _bound: boolean = false;
private _staticColor: number[];
private _sourceId: string = '';
private _entitySelect: EntitySelect | null = null;
private _colorRow!: HTMLElement;
private _vsRow!: HTMLElement;
private _colorInput!: HTMLInputElement;
private _toggleBtn!: HTMLButtonElement;
private _select!: HTMLSelectElement;
constructor(opts: BindableColorOpts) {
this._opts = opts;
this._container = opts.container;
this._staticColor = [...opts.default];
this._id = opts.idPrefix || `bcw-${++_widgetCounter}`;
this._render();
}
private _rgbToHex(rgb: number[]): string {
return '#' + rgb.map(c => Math.max(0, Math.min(255, c)).toString(16).padStart(2, '0')).join('');
}
private _hexToRgb(hex: string): number[] {
const m = hex.replace('#', '').match(/.{2}/g);
return m ? m.map(h => parseInt(h, 16)) : [255, 255, 255];
}
private _render(): void {
const id = this._id;
const hex = this._rgbToHex(this._staticColor);
const toggleSvg = `<svg class="icon icon-xs" viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>`;
const colorHtml = `<div class="bindable-slider-row" id="${id}-color-row">
<input type="color" id="${id}-color" value="${hex}">
<button type="button" class="bindable-toggle" id="${id}-toggle" title="${t('bindable.toggle')}">${toggleSvg}</button>
</div>`;
const vsHtml = `<div class="bindable-vs-row" id="${id}-vs-row" style="display:none">
<select id="${id}-select"></select>
<button type="button" class="bindable-toggle bindable-toggle--active" id="${id}-untoggle" title="${t('bindable.toggle')}">${toggleSvg}</button>
</div>`;
this._container.innerHTML = colorHtml + vsHtml;
this._colorRow = document.getElementById(`${id}-color-row`)!;
this._vsRow = document.getElementById(`${id}-vs-row`)!;
this._colorInput = document.getElementById(`${id}-color`) as HTMLInputElement;
this._select = document.getElementById(`${id}-select`) as HTMLSelectElement;
this._toggleBtn = document.getElementById(`${id}-toggle`) as HTMLButtonElement;
this._colorInput.addEventListener('input', () => {
this._staticColor = this._hexToRgb(this._colorInput.value);
this._fireChange();
});
this._toggleBtn.addEventListener('click', () => this._setMode(true));
document.getElementById(`${id}-untoggle`)!.addEventListener('click', () => this._setMode(false));
}
private _setMode(bound: boolean): void {
this._bound = bound;
this._colorRow.style.display = bound ? 'none' : '';
this._vsRow.style.display = bound ? '' : 'none';
if (bound) {
this._populateVsSelect();
} else {
this._sourceId = '';
if (this._entitySelect) { this._entitySelect.destroy(); this._entitySelect = null; }
}
this._fireChange();
}
private _populateVsSelect(): void {
// Filter to only color value sources
const sources = this._opts.valueSources().filter(vs => vs.return_type === 'color');
this._select.innerHTML = `<option value="">${this._opts.noneLabel || t('bindable.none')}</option>` +
sources.map(vs =>
`<option value="${vs.id}"${vs.id === this._sourceId ? ' selected' : ''}>${vs.name}</option>`
).join('');
if (this._entitySelect) this._entitySelect.destroy();
this._entitySelect = new EntitySelect({
target: this._select,
getItems: () => sources.map(vs => ({
value: vs.id,
label: vs.name,
icon: getValueSourceIcon(vs.source_type),
desc: vs.source_type,
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('bindable.none'),
onChange: (value: string) => {
this._sourceId = value;
this._fireChange();
},
});
}
private _fireChange(): void {
if (this._opts.onChange) {
this._opts.onChange(this.getValue());
}
}
getValue(): BindableColor {
if (this._bound && this._sourceId) {
return { color: [...this._staticColor], source_id: this._sourceId };
}
return [...this._staticColor];
}
setValue(bc: BindableColor | undefined): void {
this._staticColor = bindableColor(bc, this._opts.default);
this._sourceId = bindableColorSourceId(bc);
this._bound = !!this._sourceId;
this._colorInput.value = this._rgbToHex(this._staticColor);
this._colorRow.style.display = this._bound ? 'none' : '';
this._vsRow.style.display = this._bound ? '' : 'none';
if (this._bound) {
this._populateVsSelect();
}
}
refresh(): void {
if (this._bound) this._populateVsSelect();
}
destroy(): void {
if (this._entitySelect) { this._entitySelect.destroy(); this._entitySelect = null; }
this._container.innerHTML = '';
}
}
@@ -48,6 +48,10 @@ const CONNECTION_MAP: ConnectionEntry[] = [
// Value sources
{ targetKind: 'value_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
{ targetKind: 'value_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
{ targetKind: 'value_source', field: 'ha_source_id', sourceKind: 'ha_source', edgeType: 'ha', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
{ targetKind: 'value_source', field: 'value_source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
{ targetKind: 'value_source', field: 'gradient_id', sourceKind: 'gradient', edgeType: 'gradient', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
{ targetKind: 'value_source', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
// Color strip sources
{ targetKind: 'color_strip_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
@@ -77,6 +81,11 @@ const CONNECTION_MAP: ConnectionEntry[] = [
{ targetKind: 'color_strip_source', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
// HA light target transition binding
{ targetKind: 'output_target', field: 'transition.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
// ── BindableColor value source edges (CSS color properties) ──
{ targetKind: 'color_strip_source', field: 'color.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'color_peak.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'fallback_color.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'default_color.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
// ── Nested fields (not drag-editable in V1) ──
{ targetKind: 'color_strip_source', field: 'layer.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
@@ -359,6 +359,13 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
const vsId = bindableSourceId((s as any)[prop]);
if (vsId) addEdge(vsId, s.id, `${prop}.source_id`);
}
// BindableColor value source edges
for (const prop of ['color', 'color_peak', 'fallback_color', 'default_color'] as const) {
const raw = (s as any)[prop];
if (raw && typeof raw === 'object' && !Array.isArray(raw) && raw.source_id) {
addEdge(raw.source_id, s.id, `${prop}.source_id`);
}
}
}
// Output target edges
@@ -287,17 +287,36 @@ export class IconSelect {
* the overlay closes and `onPick(value)` is called. Clicking the backdrop
* or pressing Escape dismisses without picking.
*/
export function showTypePicker({ title, items, onPick }: { title: string; items: IconSelectItem[]; onPick: (value: string) => void }) {
export interface FilterTab {
key: string;
label: string;
}
export function showTypePicker({ title, items, onPick, filterTabs, onFilterChange }: {
title: string;
items: IconSelectItem[];
onPick: (value: string) => void;
filterTabs?: FilterTab[];
onFilterChange?: (key: string) => IconSelectItem[];
}) {
const showFilter = items.length > 9;
// Build cells
const cells = items.map(item =>
`<div class="icon-select-cell" data-value="${item.value}" data-search="${(item.label + ' ' + (item.desc || '')).toLowerCase()}">
<span class="icon-select-cell-icon">${item.icon}</span>
<span class="icon-select-cell-label">${item.label}</span>
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
</div>`
).join('');
function buildCells(cellItems: IconSelectItem[]): string {
return cellItems.map(item =>
`<div class="icon-select-cell" data-value="${item.value}" data-search="${(item.label + ' ' + (item.desc || '')).toLowerCase()}">
<span class="icon-select-cell-icon">${item.icon}</span>
<span class="icon-select-cell-label">${item.label}</span>
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
</div>`
).join('');
}
// Build filter tabs HTML
const tabsHtml = filterTabs && filterTabs.length > 0
? `<div class="type-picker-tabs">${filterTabs.map((tab, i) =>
`<button class="type-picker-tab${i === 0 ? ' active' : ''}" data-filter-key="${tab.key}">${tab.label}</button>`
).join('')}</div>`
: '';
// Create overlay
const overlay = document.createElement('div');
@@ -305,26 +324,52 @@ export function showTypePicker({ title, items, onPick }: { title: string; items:
overlay.innerHTML = `
<div class="type-picker-dialog">
<div class="type-picker-title">${title}</div>
${tabsHtml}
${showFilter ? '<input class="type-picker-filter" type="text" placeholder="Filter…" autocomplete="off">' : ''}
<div class="icon-select-grid">${cells}</div>
<div class="icon-select-grid">${buildCells(items)}</div>
</div>`;
document.body.appendChild(overlay);
const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey); };
const grid = overlay.querySelector('.icon-select-grid') as HTMLElement;
function bindCellClicks() {
grid.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.addEventListener('click', () => {
if (cell.classList.contains('disabled')) return;
close();
onPick((cell as HTMLElement).dataset.value!);
});
});
}
bindCellClicks();
// Filter tabs logic
if (filterTabs && onFilterChange) {
overlay.querySelectorAll('.type-picker-tab').forEach(btn => {
btn.addEventListener('click', () => {
overlay.querySelectorAll('.type-picker-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const key = (btn as HTMLElement).dataset.filterKey!;
const newItems = onFilterChange(key);
grid.innerHTML = buildCells(newItems);
bindCellClicks();
});
});
}
// Filter logic
if (showFilter) {
const input = overlay.querySelector('.type-picker-filter') as HTMLInputElement;
const allCells = overlay.querySelectorAll('.icon-select-cell');
input.addEventListener('input', () => {
const q = input.value.toLowerCase().trim();
allCells.forEach(cell => {
grid.querySelectorAll('.icon-select-cell').forEach(cell => {
const el = cell as HTMLElement;
const match = !q || el.dataset.search!.includes(q);
el.classList.toggle('disabled', !match);
});
});
// Auto-focus filter after animation (skip on touch devices to avoid keyboard popup)
requestAnimationFrame(() => setTimeout(() => desktopFocus(input), 200));
}
@@ -339,15 +384,6 @@ export function showTypePicker({ title, items, onPick }: { title: string; items:
};
document.addEventListener('keydown', onKey);
// Cell clicks
overlay.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.addEventListener('click', () => {
if (cell.classList.contains('disabled')) return;
close();
onPick((cell as HTMLElement).dataset.value!);
});
});
// Animate in
requestAnimationFrame(() => overlay.classList.add('open'));
}
@@ -34,6 +34,10 @@ const _valueSourceTypeIcons = {
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
adaptive_time: _svg(P.clock), adaptive_scene: _svg(P.cloudSun),
daylight: _svg(P.sun),
static_color: _svg(P.palette), animated_color: _svg(P.refreshCw),
adaptive_time_color: _svg(P.clock),
ha_entity: _svg(P.home), gradient_map: _svg(P.rainbow),
css_extract: _svg(P.droplets),
};
const _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume2), band_extract: _svg(P.activity) };
const _deviceTypeIcons = {
@@ -298,6 +302,8 @@ export const ICON_TARGET_ICON = _svg(P.target);
export const ICON_TRENDING_UP = _svg(P.trendingUp);
export const ICON_ACTIVITY = _svg(P.activity);
export const ICON_MOVE_VERTICAL = _svg(P.moveVertical);
export const ICON_HOME = _svg(P.home);
export const ICON_DROPLETS = _svg(P.droplets);
export const ICON_SUN_DIM = _svg(P.sunDim);
export const ICON_CAMERA = _svg(P.camera);
export const ICON_WRENCH = _svg(P.wrench);
@@ -296,10 +296,12 @@ export const automationsCacheObj = new DataCache<Automation[]>({
});
automationsCacheObj.subscribe(v => { _automationsCache = v; });
export let _cachedColorStripSources: ColorStripSource[] = [];
export const colorStripSourcesCache = new DataCache<ColorStripSource[]>({
endpoint: '/color-strip-sources',
extractData: json => json.sources || [],
});
colorStripSourcesCache.subscribe(v => { _cachedColorStripSources = v; });
export const csptCache = new DataCache<ColorStripProcessingTemplate[]>({
endpoint: '/color-strip-processing-templates',
@@ -1,6 +1,9 @@
/**
* Tab indicator — large semi-transparent blurred icon on the right side
* of the viewport, reflecting the currently active tab.
*
* Visible whenever any background effect is active (Noise Field, shader,
* CSS-based). Hidden only when no background effect is selected.
*/
const TAB_SVGS = {
@@ -23,14 +26,21 @@ function _ensureEl() {
return _el;
}
/** Check if any background effect is currently active. */
function _isBgActive(): boolean {
const html = document.documentElement;
return html.getAttribute('data-bg-anim') === 'on'
|| html.hasAttribute('data-bg-effect');
}
export function updateTabIndicator(tabName) {
if (tabName === _currentTab) return;
_currentTab = tabName;
const svg = TAB_SVGS[tabName];
if (!svg) return;
// Respect the dynamic background toggle — hide when bg-anim is off
if (document.documentElement.getAttribute('data-bg-anim') !== 'on') {
// Hide when no background effect is active
if (!_isBgActive()) {
const el = _ensureEl();
el.classList.remove('tab-indicator-visible');
return;
@@ -48,11 +58,10 @@ export function updateTabIndicator(tabName) {
export function initTabIndicator() {
_ensureEl();
// Listen for bg-anim toggle to show/hide the indicator
// Listen for bg-anim and bg-effect changes to show/hide the indicator
new MutationObserver(() => {
const on = document.documentElement.getAttribute('data-bg-anim') === 'on';
const el = _ensureEl();
if (!on) {
if (!_isBgActive()) {
el.classList.remove('tab-indicator-visible');
} else if (_currentTab) {
// Re-trigger show for the current tab
@@ -60,7 +69,7 @@ export function initTabIndicator() {
_currentTab = null;
updateTabIndicator(prev);
}
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-bg-anim'] });
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-bg-anim', 'data-bg-effect'] });
// Set initial tab from current active button
const active = document.querySelector('.tab-btn.active') as HTMLElement | null;
@@ -107,6 +107,8 @@ export function closeLightbox(event?: Event) {
if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
img.src = '';
img.style.display = '';
const content = lightbox.querySelector('.lightbox-content') as HTMLElement | null;
if (content) content.style.width = ''; // Reset any custom width
document.getElementById('lightbox-stats')!.style.display = 'none';
const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null;
if (spinner) spinner.style.display = 'none';
@@ -29,6 +29,7 @@ interface MonitorRect {
interface CalibrationState {
cssId: string | null;
sourceType: string;
lines: CalibrationLine[];
monitors: MonitorRect[];
pictureSources: PictureSource[];
@@ -94,6 +95,7 @@ const LINE_THICKNESS_PX = 6;
let _state: CalibrationState = {
cssId: null,
sourceType: 'picture_advanced',
lines: [],
monitors: [],
pictureSources: [],
@@ -122,6 +124,7 @@ class AdvancedCalibrationModal extends Modal {
onForceClose(): void {
if (_lineSourceEntitySelect) { _lineSourceEntitySelect.destroy(); _lineSourceEntitySelect = null; }
_state.cssId = null;
_state.sourceType = 'picture_advanced';
_state.lines = [];
_state.totalLedCount = 0;
_state.selectedLine = -1;
@@ -144,6 +147,7 @@ export async function showAdvancedCalibration(cssId: string): Promise<void> {
const psList = psResp.ok ? ((await psResp.json()).streams || []) : [];
_state.cssId = cssId;
_state.sourceType = source.source_type || 'picture_advanced';
_state.pictureSources = psList;
_state.totalLedCount = source.led_count || 0;
@@ -219,7 +223,7 @@ export async function saveAdvancedCalibration(): Promise<void> {
try {
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
method: 'PUT',
body: JSON.stringify({ calibration }),
body: JSON.stringify({ source_type: _state.sourceType, calibration }),
});
if (resp.ok) {
@@ -391,7 +391,7 @@ export function applyBgEffect(id: string): void {
/** Restore saved presets on page load. Called from init. */
export function initAppearance(): void {
_activeStyleId = localStorage.getItem(LS_STYLE_PRESET) || 'default';
_activeBgEffectId = localStorage.getItem(LS_BG_EFFECT) || 'none';
_activeBgEffectId = localStorage.getItem(LS_BG_EFFECT) || 'noise';
// Apply style preset silently (without toast)
const preset = STYLE_PRESETS.find(p => p.id === _activeStyleId);
@@ -241,9 +241,10 @@ export async function showCSSCalibration(cssId: any) {
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
const calibration: Calibration = source.calibration || {} as Calibration;
// Set CSS mode — clear device-id, set css-id
// Set CSS mode — clear device-id, set css-id and source type
(document.getElementById('calibration-device-id') as HTMLInputElement).value = '';
(document.getElementById('calibration-css-id') as HTMLInputElement).value = cssId;
(document.getElementById('calibration-css-source-type') as HTMLInputElement).value = source.source_type || 'picture';
// Populate device picker for edge test
const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement;
@@ -931,9 +932,10 @@ export async function saveCalibration() {
try {
let response;
if (cssMode) {
const cssSourceType = (document.getElementById('calibration-css-source-type') as HTMLInputElement).value || 'picture';
response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
method: 'PUT',
body: JSON.stringify({ calibration, led_count: declaredLedCount }),
body: JSON.stringify({ source_type: cssSourceType, calibration, led_count: declaredLedCount }),
});
} else {
response = await fetchWithAuth(`/devices/${deviceId}/calibration`, {
@@ -16,6 +16,7 @@ import { attachNotificationAppPicker, NotificationAppPalette } from '../core/pro
import { _cachedAssets, _cachedValueSources, assetsCache } from '../core/state.ts';
import { getBaseOrigin } from './settings.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
import { BindableColorWidget } from '../core/bindable-color.ts';
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -38,6 +39,33 @@ export function notificationGetRawAppOverrides() {
let _notificationEffectIconSelect: any = null;
let _notificationFilterModeIconSelect: any = null;
let _notificationDurationWidget: BindableScalarWidget | null = null;
let _notificationDefaultColorWidget: BindableColorWidget | null = null;
let _notificationVolumeWidget: BindableScalarWidget | null = null;
function _ensureNotificationVolumeWidget(): BindableScalarWidget {
if (!_notificationVolumeWidget) {
_notificationVolumeWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-notification-volume-container')!,
min: 0, max: 1, step: 0.05, default: 1.0,
idPrefix: 'css-editor-notification-volume',
format: (v) => `${Math.round(v * 100)}%`,
valueSources: () => _cachedValueSources,
});
}
return _notificationVolumeWidget;
}
function _ensureNotificationDefaultColorWidget(): BindableColorWidget {
if (!_notificationDefaultColorWidget) {
_notificationDefaultColorWidget = new BindableColorWidget({
container: document.getElementById('css-editor-notification-default-color-container')!,
default: [255, 255, 255],
idPrefix: 'css-editor-notification-default-color',
valueSources: () => _cachedValueSources,
});
}
return _notificationDefaultColorWidget;
}
function _ensureNotificationDurationWidget(): BindableScalarWidget {
if (!_notificationDurationWidget) {
@@ -54,6 +82,8 @@ function _ensureNotificationDurationWidget(): BindableScalarWidget {
export function destroyNotificationDurationWidget(): void {
if (_notificationDurationWidget) { _notificationDurationWidget.destroy(); _notificationDurationWidget = null; }
if (_notificationDefaultColorWidget) { _notificationDefaultColorWidget.destroy(); _notificationDefaultColorWidget = null; }
if (_notificationVolumeWidget) { _notificationVolumeWidget.destroy(); _notificationVolumeWidget = null; }
}
export function getNotificationDurationValue(): number | { value: number; source_id: string } {
@@ -64,6 +94,22 @@ export function getNotificationDurationSnapshot(): string {
return _notificationDurationWidget ? JSON.stringify(_notificationDurationWidget.getValue()) : '1500';
}
export function getNotificationDefaultColorValue(): any {
return _notificationDefaultColorWidget ? _notificationDefaultColorWidget.getValue() : [255, 255, 255];
}
export function getNotificationVolumeValue(): any {
return _notificationVolumeWidget ? _notificationVolumeWidget.getValue() : 1.0;
}
export function getNotificationVolumeSnapshot(): string {
return _notificationVolumeWidget ? JSON.stringify(_notificationVolumeWidget.getValue()) : '1.0';
}
export function getNotificationDefaultColorSnapshot(): string {
return _notificationDefaultColorWidget ? JSON.stringify(_notificationDefaultColorWidget.getValue()) : '[255,255,255]';
}
export function ensureNotificationEffectIconSelect() {
const sel = document.getElementById('css-editor-notification-effect') as HTMLSelectElement | null;
if (!sel) return;
@@ -374,7 +420,7 @@ export async function loadNotificationState(css: any) {
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
_ensureNotificationDurationWidget().setValue(css.duration_ms ?? 1500);
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = css.default_color || '#ffffff';
_ensureNotificationDefaultColorWidget().setValue(css.default_color);
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = css.app_filter_mode || 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(css.app_filter_mode || 'off');
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = (css.app_filter_list || []).join('\n');
@@ -390,9 +436,7 @@ export async function loadNotificationState(css: any) {
if (soundSel) soundSel.value = css.sound_asset_id || '';
ensureNotifSoundEntitySelect();
if (_notifSoundEntitySelect && css.sound_asset_id) _notifSoundEntitySelect.setValue(css.sound_asset_id);
const volPct = Math.round((css.sound_volume ?? 1.0) * 100);
(document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = volPct as any;
(document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = `${volPct}%`;
_ensureNotificationVolumeWidget().setValue(css.sound_volume ?? 1.0);
// Unified per-app overrides (merge app_colors + app_sounds)
_notificationAppOverrides = _mergeOverrides(css.app_colors || {}, css.app_sounds || {});
@@ -406,7 +450,7 @@ export async function resetNotificationState() {
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
_ensureNotificationDurationWidget().setValue(1500);
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = '#ffffff';
_ensureNotificationDefaultColorWidget().setValue([255, 255, 255]);
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off');
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = '';
@@ -418,8 +462,7 @@ export async function resetNotificationState() {
_populateSoundOptions(soundSel);
if (soundSel) soundSel.value = '';
ensureNotifSoundEntitySelect();
(document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = 100 as any;
(document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = '100%';
_ensureNotificationVolumeWidget().setValue(1.0);
// Clear overrides
_notificationAppOverrides = [];
@@ -15,7 +15,7 @@ import {
import { EntitySelect } from '../core/entity-palette.ts';
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue } from './color-strips-notification.ts';
import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue, getNotificationVolumeValue } from './color-strips-notification.ts';
/* ── Preview config builder ───────────────────────────────────── */
@@ -55,7 +55,7 @@ function _collectPreviewConfig() {
app_filter_list: filterList,
app_colors: notificationGetAppColorsDict(),
sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null,
sound_volume: parseInt((document.getElementById('css-editor-notification-volume') as HTMLInputElement).value) / 100,
sound_volume: getNotificationVolumeValue(),
app_sounds: notificationGetAppSoundsDict(),
};
}
@@ -167,8 +167,11 @@ function _testKeyColorsSource(sourceId: string) {
// Show lightbox with spinner
const lightbox = document.getElementById('image-lightbox')!;
const spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement | null;
const content = lightbox.querySelector('.lightbox-content') as HTMLElement | null;
if (content) content.style.width = '90vw'; // Fill viewport for KC preview
const img = document.getElementById('lightbox-image') as HTMLImageElement;
img.src = '';
img.style.display = 'none'; // Hide until first frame arrives
if (spinner) spinner.style.display = '';
document.getElementById('lightbox-stats')!.style.display = 'none';
lightbox.classList.add('active');
@@ -18,11 +18,12 @@ import {
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ColorStripSource } from '../types.ts';
import { bindableValue } from '../types.ts';
import { bindableValue, bindableColor } from '../types.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
import { BindableColorWidget } from '../core/bindable-color.ts';
import { getBaseOrigin } from './settings.ts';
import {
rgbArrayToHex, hexToRgbArray,
@@ -42,6 +43,8 @@ import {
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
loadNotificationState, resetNotificationState, showNotificationEndpoint,
destroyNotificationDurationWidget, getNotificationDurationValue, getNotificationDurationSnapshot,
getNotificationDefaultColorValue, getNotificationDefaultColorSnapshot,
getNotificationVolumeValue, getNotificationVolumeSnapshot,
} from './color-strips-notification.ts';
// Re-export for app.js window global bindings
@@ -72,6 +75,12 @@ class CSSEditorModal extends Modal {
if (_candlelightWindWidget) { _candlelightWindWidget.destroy(); _candlelightWindWidget = null; }
if (_weatherSpeedWidget) { _weatherSpeedWidget.destroy(); _weatherSpeedWidget = null; }
if (_weatherTempInfluenceWidget) { _weatherTempInfluenceWidget.destroy(); _weatherTempInfluenceWidget = null; }
if (_staticColorWidget) { _staticColorWidget.destroy(); _staticColorWidget = null; }
if (_effectColorWidget) { _effectColorWidget.destroy(); _effectColorWidget = null; }
if (_audioColorWidget) { _audioColorWidget.destroy(); _audioColorWidget = null; }
if (_audioColorPeakWidget) { _audioColorPeakWidget.destroy(); _audioColorPeakWidget = null; }
if (_apiInputFallbackColorWidget) { _apiInputFallbackColorWidget.destroy(); _apiInputFallbackColorWidget = null; }
if (_candlelightColorWidget) { _candlelightColorWidget.destroy(); _candlelightColorWidget = null; }
destroyNotificationDurationWidget();
if (_kcPictureSourceEntitySelect) { _kcPictureSourceEntitySelect.destroy(); _kcPictureSourceEntitySelect = null; }
if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; }
@@ -88,14 +97,14 @@ class CSSEditorModal extends Modal {
picture_source: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
interpolation: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
smoothing: _smoothingWidget ? JSON.stringify(_smoothingWidget.getValue()) : '0.3',
color: (document.getElementById('css-editor-color') as HTMLInputElement).value,
color: _staticColorWidget ? JSON.stringify(_staticColorWidget.getValue()) : '[]',
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
animation_type: (document.getElementById('css-editor-animation-type') as HTMLInputElement).value,
cycle_colors: JSON.stringify(_colorCycleColors),
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
effect_palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
effect_color: (document.getElementById('css-editor-effect-color') as HTMLInputElement).value,
effect_color: _effectColorWidget ? JSON.stringify(_effectColorWidget.getValue()) : '[]',
effect_intensity: _effectIntensityWidget ? JSON.stringify(_effectIntensityWidget.getValue()) : '1.0',
effect_scale: _effectScaleWidget ? JSON.stringify(_effectScaleWidget.getValue()) : '1.0',
effect_mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
@@ -106,16 +115,16 @@ class CSSEditorModal extends Modal {
audio_sensitivity: _audioSensitivityWidget ? JSON.stringify(_audioSensitivityWidget.getValue()) : '1.0',
audio_smoothing: _audioSmoothingWidget ? JSON.stringify(_audioSmoothingWidget.getValue()) : '0.3',
audio_palette: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
audio_color: (document.getElementById('css-editor-audio-color') as HTMLInputElement).value,
audio_color_peak: (document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value,
audio_color: _audioColorWidget ? JSON.stringify(_audioColorWidget.getValue()) : '[]',
audio_color_peak: _audioColorPeakWidget ? JSON.stringify(_audioColorPeakWidget.getValue()) : '[]',
audio_mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked,
api_input_fallback_color: (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value,
api_input_fallback_color: _apiInputFallbackColorWidget ? JSON.stringify(_apiInputFallbackColorWidget.getValue()) : '[]',
api_input_timeout: _apiInputTimeoutWidget ? JSON.stringify(_apiInputTimeoutWidget.getValue()) : '5.0',
api_input_interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLInputElement).value,
notification_os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
notification_duration: getNotificationDurationSnapshot(),
notification_default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
notification_default_color: getNotificationDefaultColorSnapshot(),
notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value,
notification_app_overrides: JSON.stringify(notificationGetRawAppOverrides()),
@@ -123,7 +132,7 @@ class CSSEditorModal extends Modal {
daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value,
daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
daylight_latitude: (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value,
candlelight_color: (document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value,
candlelight_color: _candlelightColorWidget ? JSON.stringify(_candlelightColorWidget.getValue()) : '[]',
candlelight_intensity: _candlelightIntensityWidget ? JSON.stringify(_candlelightIntensityWidget.getValue()) : '1.0',
candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value,
candlelight_speed: _candlelightSpeedWidget ? JSON.stringify(_candlelightSpeedWidget.getValue()) : '1.0',
@@ -152,6 +161,14 @@ let _candlelightWindWidget: BindableScalarWidget | null = null;
let _weatherSpeedWidget: BindableScalarWidget | null = null;
let _weatherTempInfluenceWidget: BindableScalarWidget | null = null;
// ── BindableColorWidget instances for CSS editor ──
let _staticColorWidget: BindableColorWidget | null = null;
let _effectColorWidget: BindableColorWidget | null = null;
let _audioColorWidget: BindableColorWidget | null = null;
let _audioColorPeakWidget: BindableColorWidget | null = null;
let _apiInputFallbackColorWidget: BindableColorWidget | null = null;
let _candlelightColorWidget: BindableColorWidget | null = null;
// ── EntitySelect instances for CSS editor ──
let _cssPictureSourceEntitySelect: any = null;
let _cssAudioSourceEntitySelect: any = null;
@@ -204,6 +221,7 @@ async function configureKCRegions(sourceId: string): Promise<void> {
if (!showPatternTemplateEditor) return;
showPatternTemplateEditor(null, null, {
rects: rects.map((r: any) => ({ ...r })),
pictureSourceId: source.picture_source_id || '',
onSave: async (newRects: any[]) => {
// Save rectangles back to the CSS source
try {
@@ -286,9 +304,9 @@ const CSS_ALL_SECTION_IDS = [...new Set(Object.values(CSS_SECTION_MAP))];
const CSS_TYPE_SETUP: Record<string, () => void> = {
processed: () => _populateProcessedSelectors(),
effect: () => { _ensureEffectTypeIconSelect(); _ensureEffectPaletteIconSelect(); onEffectTypeChange(); },
audio: () => { _ensureAudioVizIconSelect(); _ensureAudioPaletteIconSelect(); onAudioVizChange(); _loadAudioSources(); },
gradient: () => { _ensureGradientPresetIconSelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); },
effect: () => { _ensureEffectTypeIconSelect(); _ensureEffectPaletteEntitySelect(); onEffectTypeChange(); },
audio: () => { _ensureAudioVizIconSelect(); _ensureAudioPaletteEntitySelect(); onAudioVizChange(); _loadAudioSources(); },
gradient: () => { _ensureGradientPresetEntitySelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); },
notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); },
candlelight: () => _ensureCandleTypeIconSelect(),
weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); },
@@ -491,10 +509,10 @@ function _syncDaylightSpeedVisibility() {
let _animationTypeIconSelect: any = null;
let _interpolationIconSelect: any = null;
let _effectTypeIconSelect: any = null;
let _effectPaletteIconSelect: any = null;
let _audioPaletteIconSelect: any = null;
let _effectPaletteEntitySelect: EntitySelect | null = null;
let _audioPaletteEntitySelect: EntitySelect | null = null;
let _audioVizIconSelect: any = null;
let _gradientPresetIconSelect: any = null;
let _gradientPresetEntitySelect: EntitySelect | null = null;
let _gradientEasingIconSelect: any = null;
let _candleTypeIconSelect: any = null;
let _apiInputInterpolationIconSelect: any = null;
@@ -681,6 +699,78 @@ function _ensureWeatherTempInfluenceWidget(): BindableScalarWidget {
return _weatherTempInfluenceWidget;
}
function _ensureStaticColorWidget(): BindableColorWidget {
if (!_staticColorWidget) {
_staticColorWidget = new BindableColorWidget({
container: document.getElementById('css-editor-color-container')!,
default: [255, 255, 255],
valueSources: () => _cachedValueSources,
idPrefix: 'css-editor-color',
});
}
return _staticColorWidget;
}
function _ensureEffectColorWidget(): BindableColorWidget {
if (!_effectColorWidget) {
_effectColorWidget = new BindableColorWidget({
container: document.getElementById('css-editor-effect-color-container')!,
default: [255, 80, 0],
valueSources: () => _cachedValueSources,
idPrefix: 'css-editor-effect-color',
});
}
return _effectColorWidget;
}
function _ensureAudioColorWidget(): BindableColorWidget {
if (!_audioColorWidget) {
_audioColorWidget = new BindableColorWidget({
container: document.getElementById('css-editor-audio-color-container')!,
default: [0, 255, 0],
valueSources: () => _cachedValueSources,
idPrefix: 'css-editor-audio-color',
});
}
return _audioColorWidget;
}
function _ensureAudioColorPeakWidget(): BindableColorWidget {
if (!_audioColorPeakWidget) {
_audioColorPeakWidget = new BindableColorWidget({
container: document.getElementById('css-editor-audio-color-peak-container')!,
default: [255, 0, 0],
valueSources: () => _cachedValueSources,
idPrefix: 'css-editor-audio-color-peak',
});
}
return _audioColorPeakWidget;
}
function _ensureApiInputFallbackColorWidget(): BindableColorWidget {
if (!_apiInputFallbackColorWidget) {
_apiInputFallbackColorWidget = new BindableColorWidget({
container: document.getElementById('css-editor-api-input-fallback-color-container')!,
default: [0, 0, 0],
valueSources: () => _cachedValueSources,
idPrefix: 'css-editor-api-input-fallback-color',
});
}
return _apiInputFallbackColorWidget;
}
function _ensureCandlelightColorWidget(): BindableColorWidget {
if (!_candlelightColorWidget) {
_candlelightColorWidget = new BindableColorWidget({
container: document.getElementById('css-editor-candlelight-color-container')!,
default: [255, 147, 41],
valueSources: () => _cachedValueSources,
idPrefix: 'css-editor-candlelight-color',
});
}
return _candlelightColorWidget;
}
function _ensureApiInputInterpolationIconSelect() {
const sel = document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement | null;
if (!sel) return;
@@ -714,13 +804,17 @@ function _ensureEffectTypeIconSelect() {
_effectTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
function _ensureEffectPaletteIconSelect() {
function _ensureEffectPaletteEntitySelect() {
const sel = document.getElementById('css-editor-effect-palette') as HTMLSelectElement | null;
if (!sel) return;
const items = _buildGradientEntityItems();
_syncSelectOptions(sel, items);
if (_effectPaletteIconSelect) { _effectPaletteIconSelect.updateItems(items); return; }
_effectPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2, searchable: true, searchPlaceholder: t('palette.search') });
if (_effectPaletteEntitySelect) { _effectPaletteEntitySelect.refresh(); return; }
_effectPaletteEntitySelect = new EntitySelect({
target: sel,
getItems: _buildGradientEntityItems,
placeholder: t('palette.search'),
});
}
function _ensureGradientEasingIconSelect() {
@@ -749,13 +843,17 @@ function _ensureCandleTypeIconSelect() {
_candleTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
function _ensureAudioPaletteIconSelect() {
function _ensureAudioPaletteEntitySelect() {
const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null;
if (!sel) return;
const items = _buildGradientEntityItems();
_syncSelectOptions(sel, items);
if (_audioPaletteIconSelect) { _audioPaletteIconSelect.updateItems(items); return; }
_audioPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2, searchable: true, searchPlaceholder: t('palette.search') });
if (_audioPaletteEntitySelect) { _audioPaletteEntitySelect.refresh(); return; }
_audioPaletteEntitySelect = new EntitySelect({
target: sel,
getItems: _buildGradientEntityItems,
placeholder: t('palette.search'),
});
}
function _ensureAudioVizIconSelect() {
@@ -788,21 +886,29 @@ function _syncSelectOptions(sel: HTMLSelectElement, items: Array<{ value: string
}
}
function _ensureGradientPresetIconSelect() {
function _ensureGradientPresetEntitySelect() {
const sel = document.getElementById('css-editor-gradient-preset') as HTMLSelectElement | null;
if (!sel) return;
const items = _buildGradientEntityItems();
_syncSelectOptions(sel, items);
if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; }
_gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3, searchable: true, searchPlaceholder: t('palette.search') });
if (_gradientPresetEntitySelect) { _gradientPresetEntitySelect.refresh(); return; }
_gradientPresetEntitySelect = new EntitySelect({
target: sel,
getItems: _buildGradientEntityItems,
placeholder: t('palette.search'),
});
}
/** Rebuild the gradient picker after entity changes. */
export function refreshGradientPresetPicker() {
const items = _buildGradientEntityItems();
if (_gradientPresetIconSelect) _gradientPresetIconSelect.updateItems(items);
if (_effectPaletteIconSelect) _effectPaletteIconSelect.updateItems(items);
if (_audioPaletteIconSelect) _audioPaletteIconSelect.updateItems(items);
// Re-sync select options before refreshing entity selects
for (const selId of ['css-editor-gradient-preset', 'css-editor-effect-palette', 'css-editor-audio-palette']) {
const sel = document.getElementById(selId) as HTMLSelectElement | null;
if (sel) _syncSelectOptions(sel, _buildGradientEntityItems());
}
if (_gradientPresetEntitySelect) _gradientPresetEntitySelect.refresh();
if (_effectPaletteEntitySelect) _effectPaletteEntitySelect.refresh();
if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.refresh();
}
/** Render the user-created gradient list below the save button. */
@@ -1198,9 +1304,9 @@ function _loadAudioState(css: any) {
const audioGradientId = css.gradient_id || 'gr_builtin_rainbow';
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId;
if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue(audioGradientId);
(document.getElementById('css-editor-audio-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [0, 255, 0]);
(document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value = rgbArrayToHex(css.color_peak || [255, 0, 0]);
if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.setValue(audioGradientId);
_ensureAudioColorWidget().setValue(css.color);
_ensureAudioColorPeakWidget().setValue(css.color_peak);
(document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = css.mirror || false;
// Set audio source selector
@@ -1216,9 +1322,9 @@ function _resetAudioState() {
_ensureAudioSensitivityWidget().setValue(1.0);
_ensureAudioSmoothingWidget().setValue(0.3);
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow';
if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue('gr_builtin_rainbow');
(document.getElementById('css-editor-audio-color') as HTMLInputElement).value = '#00ff00';
(document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value = '#ff0000';
if (_audioPaletteEntitySelect) _audioPaletteEntitySelect.setValue('gr_builtin_rainbow');
_ensureAudioColorWidget().setValue([0, 255, 0]);
_ensureAudioColorPeakWidget().setValue([255, 0, 0]);
(document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = false;
}
@@ -1238,7 +1344,7 @@ const NON_PICTURE_TYPES = new Set([
const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
static: (source, { clockBadge, animBadge }) => {
const hexColor = rgbArrayToHex(source.color!);
const hexColor = rgbArrayToHex(bindableColor(source.color, [255,255,255]));
return `
<span class="stream-card-prop" title="${t('color_strip.static_color')}">
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
@@ -1320,7 +1426,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
`;
},
api_input: (source) => {
const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]);
const fbColor = rgbArrayToHex(bindableColor(source.fallback_color, [0, 0, 0]));
const timeoutVal = bindableValue(source.timeout, 5.0).toFixed(1);
const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear';
return `
@@ -1334,13 +1440,14 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
notification: (source) => {
const effectLabel = t('color_strip.notification.effect.' + (source.notification_effect || 'flash')) || source.notification_effect || 'flash';
const durationVal = source.duration_ms || 1500;
const defColor = source.default_color || '#FFFFFF';
const defColorRgb = bindableColor(source.default_color as any, [255, 255, 255]);
const defColorHex = rgbArrayToHex(defColorRgb);
const appCount = source.app_colors ? Object.keys(source.app_colors).length : 0;
return `
<span class="stream-card-prop" title="${t('color_strip.notification.effect')}">${ICON_BELL} ${escapeHtml(effectLabel)}</span>
<span class="stream-card-prop" title="${t('color_strip.notification.duration')}">${ICON_TIMER} ${durationVal}ms</span>
<span class="stream-card-prop" title="${t('color_strip.notification.default_color')}">
<span style="display:inline-block;width:14px;height:14px;background:${defColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${defColor.toUpperCase()}
<span style="display:inline-block;width:14px;height:14px;background:${defColorHex};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${defColorHex.toUpperCase()}
</span>
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
`;
@@ -1354,7 +1461,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
`;
},
candlelight: (source, { clockBadge }) => {
const hexColor = rgbArrayToHex(source.color || [255, 147, 41]);
const hexColor = rgbArrayToHex(bindableColor(source.color, [255, 147, 41]));
const numCandles = source.num_candles ?? 3;
return `
<span class="stream-card-prop" title="${t('color_strip.candlelight.color')}">
@@ -1539,17 +1646,17 @@ function _autoGenerateCSSName() {
const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...args: any[]) => any; getPayload: (name: any) => any }> = {
static: {
load(css) {
(document.getElementById('css-editor-color') as HTMLInputElement).value = rgbArrayToHex(css.color);
_ensureStaticColorWidget().setValue(css.color);
_loadAnimationState(css.animation);
},
reset() {
(document.getElementById('css-editor-color') as HTMLInputElement).value = '#ffffff';
_ensureStaticColorWidget().setValue([255, 255, 255]);
_loadAnimationState(null);
},
getPayload(name) {
return {
name,
color: hexToRgbArray((document.getElementById('css-editor-color') as HTMLInputElement).value),
color: _ensureStaticColorWidget().getValue(),
animation: _getAnimationPayload(),
};
},
@@ -1573,20 +1680,20 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
gradient: {
load(css) {
const gradientId = css.gradient_id || '';
_ensureGradientPresetIconSelect();
_ensureGradientPresetEntitySelect();
(document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = gradientId;
if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue(gradientId);
if (_gradientPresetEntitySelect) _gradientPresetEntitySelect.setValue(gradientId);
_loadAnimationState(css.animation);
(document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = css.easing || 'linear';
if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue(css.easing || 'linear');
},
reset() {
_ensureGradientPresetIconSelect();
_ensureGradientPresetEntitySelect();
// Default to first gradient
const gradients = _getGradients();
const defaultId = gradients.length > 0 ? gradients[0].id : '';
(document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = defaultId;
if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue(defaultId);
if (_gradientPresetEntitySelect) _gradientPresetEntitySelect.setValue(defaultId);
_loadAnimationState(null);
(document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = 'linear';
if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue('linear');
@@ -1612,8 +1719,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
onEffectTypeChange();
const gradientId = css.gradient_id || 'gr_builtin_fire';
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = gradientId;
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(gradientId);
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 80, 0]);
if (_effectPaletteEntitySelect) _effectPaletteEntitySelect.setValue(gradientId);
_ensureEffectColorWidget().setValue(css.color);
_ensureEffectIntensityWidget().setValue(css.intensity ?? 1.0);
_ensureEffectScaleWidget().setValue(css.scale ?? 1.0);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false;
@@ -1622,8 +1729,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
reset() {
(document.getElementById('css-editor-effect-type') as HTMLInputElement).value = 'fire';
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = 'gr_builtin_fire';
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue('gr_builtin_fire');
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = '#ff5000';
if (_effectPaletteEntitySelect) _effectPaletteEntitySelect.setValue('gr_builtin_fire');
_ensureEffectColorWidget().setValue([255, 80, 0]);
_ensureEffectIntensityWidget().setValue(1.0);
_ensureEffectScaleWidget().setValue(1.0);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false;
@@ -1639,8 +1746,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
};
// Meteor/comet/bouncing_ball use a color picker
if (['meteor', 'comet', 'bouncing_ball'].includes(payload.effect_type)) {
const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value;
payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
payload.color = _ensureEffectColorWidget().getValue();
}
return payload;
},
@@ -1661,8 +1767,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
sensitivity: _ensureAudioSensitivityWidget().getValue(),
smoothing: _ensureAudioSmoothingWidget().getValue(),
gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
color: hexToRgbArray((document.getElementById('css-editor-audio-color') as HTMLInputElement).value),
color_peak: hexToRgbArray((document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value),
color: _ensureAudioColorWidget().getValue(),
color_peak: _ensureAudioColorPeakWidget().getValue(),
mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked,
};
},
@@ -1707,25 +1813,23 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
},
api_input: {
load(css) {
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value =
rgbArrayToHex(css.fallback_color || [0, 0, 0]);
_ensureApiInputFallbackColorWidget().setValue(css.fallback_color);
_ensureApiInputTimeoutWidget().setValue(css.timeout ?? 5.0);
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = css.interpolation || 'linear';
_ensureApiInputInterpolationIconSelect();
_showApiInputEndpoints(css.id);
},
reset() {
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value = '#000000';
_ensureApiInputFallbackColorWidget().setValue([0, 0, 0]);
_ensureApiInputTimeoutWidget().setValue(5.0);
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = 'linear';
_ensureApiInputInterpolationIconSelect();
_showApiInputEndpoints(null);
},
getPayload(name) {
const fbHex = (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value;
return {
name,
fallback_color: hexToRgbArray(fbHex),
fallback_color: _ensureApiInputFallbackColorWidget().getValue(),
timeout: _ensureApiInputTimeoutWidget().getValue(),
interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value,
};
@@ -1746,12 +1850,12 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
duration_ms: getNotificationDurationValue(),
default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
default_color: getNotificationDefaultColorValue(),
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
app_filter_list: filterList,
app_colors: notificationGetAppColorsDict(),
sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null,
sound_volume: parseInt((document.getElementById('css-editor-notification-volume') as HTMLInputElement).value) / 100,
sound_volume: getNotificationVolumeValue(),
app_sounds: notificationGetAppSoundsDict(),
};
},
@@ -1788,7 +1892,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
},
candlelight: {
load(css) {
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 147, 41]);
_ensureCandlelightColorWidget().setValue(css.color);
_ensureCandlelightIntensityWidget().setValue(css.intensity ?? 1.0);
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = css.num_candles ?? 3;
_ensureCandlelightSpeedWidget().setValue(css.speed ?? 1.0);
@@ -1797,7 +1901,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue(css.candle_type || 'default');
},
reset() {
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = '#ff9329';
_ensureCandlelightColorWidget().setValue([255, 147, 41]);
_ensureCandlelightIntensityWidget().setValue(1.0);
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = 3 as any;
_ensureCandlelightSpeedWidget().setValue(1.0);
@@ -1808,7 +1912,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
getPayload(name) {
return {
name,
color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value),
color: _ensureCandlelightColorWidget().getValue(),
intensity: _ensureCandlelightIntensityWidget().getValue(),
num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3,
speed: _ensureCandlelightSpeedWidget().getValue(),
@@ -2172,7 +2276,7 @@ export async function saveCSSEditor() {
const payload = handler.getPayload(name);
if (payload === null) return; // validation error already shown
if (!cssId) payload.source_type = knownType ? sourceType : 'picture';
payload.source_type = knownType ? sourceType : 'picture';
// Attach clock_id for animated types
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather'];
@@ -354,10 +354,12 @@ function _sectionHeader(sectionKey: string, label: string, count: number | strin
const collapsed = _getCollapsedSections();
const isCollapsed = !!collapsed[sectionKey];
const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}" onclick="toggleDashboardSection('${sectionKey}')">
<span class="dashboard-section-chevron"${chevronStyle}>&#9654;</span>
${label}
<span class="dashboard-section-count">${count}</span>
return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}">
<span class="dashboard-section-toggle" onclick="toggleDashboardSection('${sectionKey}')">
<span class="dashboard-section-chevron"${chevronStyle}>&#9654;</span>
${label}
<span class="dashboard-section-count">${count}</span>
</span>
${extraHtml}
</div>`;
}
@@ -682,7 +682,7 @@ async function _fetchAllEntities(): Promise<Record<string, any>> {
devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(),
streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(),
valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(),
outputTargetsCache.fetch(), patternTemplatesCache.fetch(), scenePresetsCache.fetch(),
outputTargetsCache.fetch(), patternTemplatesCache.fetch().catch(() => []), scenePresetsCache.fetch(),
automationsCacheObj.fetch(), csptCache.fetch(),
fetchWithAuth('/output-targets/batch/states').catch(() => null),
]);
@@ -7,7 +7,7 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.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, getHAEntityIcon } from '../core/icons.ts';
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, ICON_SUN, getColorStripIcon, getValueSourceIcon, getHAEntityIcon } 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';
@@ -23,7 +23,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 _brightnessWidget: BindableScalarWidget | null = null;
let _mappingEntitySelects: EntitySelect[] = [];
let _editorCssSources: any[] = [];
let _cachedHAEntities: any[] = []; // fetched from selected HA source
@@ -39,7 +39,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; }
if (_brightnessWidget) { _brightnessWidget.destroy(); _brightnessWidget = null; }
if (_updateRateWidget) { _updateRateWidget.destroy(); _updateRateWidget = null; }
if (_transitionWidget) { _transitionWidget.destroy(); _transitionWidget = null; }
if (_colorToleranceWidget) { _colorToleranceWidget.destroy(); _colorToleranceWidget = null; }
@@ -52,6 +52,7 @@ class HALightEditorModal extends Modal {
name: (document.getElementById('ha-light-editor-name') as HTMLInputElement).value,
ha_source: (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value,
css_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value,
brightness: _brightnessWidget ? JSON.stringify(_brightnessWidget.getValue()) : '1.0',
update_rate: _updateRateWidget ? JSON.stringify(_updateRateWidget.getValue()) : '2.0',
transition: _transitionWidget ? JSON.stringify(_transitionWidget.getValue()) : '0.5',
color_tolerance: _colorToleranceWidget ? JSON.stringify(_colorToleranceWidget.getValue()) : '5',
@@ -215,6 +216,19 @@ export function removeHALightMapping(btn: HTMLElement): void {
// ── Bindable scalar widgets ──
function _ensureBrightnessWidget(): BindableScalarWidget {
if (!_brightnessWidget) {
_brightnessWidget = new BindableScalarWidget({
container: document.getElementById('ha-light-editor-brightness-container')!,
min: 0, max: 1, step: 0.05, default: 1.0,
idPrefix: 'ha-light-editor-brightness',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(2),
});
}
return _brightnessWidget;
}
function _ensureUpdateRateWidget(): BindableScalarWidget {
if (!_updateRateWidget) {
_updateRateWidget = new BindableScalarWidget({
@@ -322,6 +336,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = editData.name || '';
haSelect.value = editData.ha_source_id || '';
cssSelect.value = editData.color_strip_source_id || '';
_ensureBrightnessWidget().setValue(editData.brightness ?? 1.0);
_ensureUpdateRateWidget().setValue(editData.update_rate ?? 2.0);
_ensureTransitionWidget().setValue(editData.transition ?? 0.5);
_ensureColorToleranceWidget().setValue(editData.color_tolerance ?? 5);
@@ -336,6 +351,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
mappings.forEach((m: any) => addHALightMapping(m));
} else {
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = '';
_ensureBrightnessWidget().setValue(1.0);
_ensureUpdateRateWidget().setValue(2.0);
_ensureTransitionWidget().setValue(0.5);
_ensureColorToleranceWidget().setValue(5);
@@ -375,23 +391,6 @@ 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 === bindableSourceId(editData?.brightness) ? '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') });
@@ -430,13 +429,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 brightness = _brightnessWidget ? _brightnessWidget.getValue() : 1.0;
const payload: any = {
name,
ha_source_id: haSourceId,
color_strip_source_id: cssSourceId,
brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0,
brightness,
ha_light_mappings: mappings,
update_rate: updateRate,
transition,
@@ -446,6 +445,8 @@ export async function saveHALightEditor(): Promise<void> {
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
};
payload.target_type = 'ha_light';
try {
let response;
if (targetId) {
@@ -454,7 +455,6 @@ export async function saveHALightEditor(): Promise<void> {
body: JSON.stringify(payload),
});
} else {
payload.target_type = 'ha_light';
response = await fetchWithAuth('/output-targets', {
method: 'POST',
body: JSON.stringify(payload),
@@ -537,7 +537,7 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
${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>` : ''}
${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>` : (bindableValue(target.brightness, 1.0) < 1.0 ? `<span class="stream-card-prop" title="${t('targets.brightness')}">${ICON_SUN} ${Math.round(bindableValue(target.brightness, 1.0) * 100)}%</span>` : '')}
</div>
${renderTagChips(target.tags || [])}
${target.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(target.description)}</div>` : ''}
@@ -101,7 +101,7 @@ export function createPatternTemplateCard(pt: PatternTemplate) {
});
}
export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null, opts?: { rects?: any[]; onSave?: (rects: any[]) => void }): Promise<void> {
export async function showPatternTemplateEditor(templateId: string | null = null, cloneData: PatternTemplate | null = null, opts?: { rects?: any[]; pictureSourceId?: string; onSave?: (rects: any[]) => void }): Promise<void> {
_inlineCallback = opts?.onSave || null;
try {
// Load sources for background capture
@@ -199,6 +199,15 @@ export async function showPatternTemplateEditor(templateId: string | null = null
(document.getElementById('pattern-template-error') as HTMLElement).style.display = 'none';
setTimeout(() => desktopFocus(document.getElementById('pattern-template-name')), 100);
// Auto-capture background from picture source (if provided)
if (opts?.pictureSourceId) {
// Pre-select the source in dropdown
if (bgSelect) bgSelect.value = opts.pictureSourceId;
if (_patternBgEntitySelect) _patternBgEntitySelect.refresh();
// Capture after a short delay to let canvas resize
setTimeout(() => capturePatternBackground(), 200);
}
} catch (error) {
console.error('Failed to open pattern template editor:', error);
showToast(t('pattern.error.editor_open_failed'), 'error');
@@ -38,7 +38,7 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }:
localStorage.setItem('activeTab', name);
// Update background tab indicator
if (typeof window._updateTabIndicator === 'function') window._updateTabIndicator();
if (typeof window._updateTabIndicator === 'function') window._updateTabIndicator(name);
// Restore scroll position for this tab
requestAnimationFrame(() => window.scrollTo(0, _tabScrollPositions[name] || 0));
@@ -23,7 +23,7 @@ import {
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW,
ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP,
ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP,
ICON_WARNING, ICON_PALETTE, ICON_WRENCH, ICON_TEMPLATE, ICON_TRASH,
} from '../core/icons.ts';
import { EntitySelect } from '../core/entity-palette.ts';
@@ -35,7 +35,7 @@ import { createFpsSparkline } from '../core/chart-utils.ts';
import { CardSection } from '../core/card-sections.ts';
import { TreeNav } from '../core/tree-nav.ts';
import { updateSubTabHash, updateTabBadge } from './tabs.ts';
import type { OutputTarget } from '../types.ts';
import type { OutputTarget, LedOutputTarget } from '../types.ts';
import { bindableSourceId, bindableValue } from '../types.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
@@ -144,6 +144,7 @@ function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
// --- Editor state ---
let _editorCssSources: any[] = []; // populated when editor opens
let _targetTagsInput: TagInput | null = null;
let _brightnessWidget: BindableScalarWidget | null = null;
let _fpsWidget: BindableScalarWidget | null = null;
let _thresholdWidget: BindableScalarWidget | null = null;
@@ -158,7 +159,7 @@ class TargetEditorModal extends Modal {
device: (document.getElementById('target-editor-device') as HTMLSelectElement).value,
protocol: (document.getElementById('target-editor-protocol') as HTMLSelectElement).value,
css_source: (document.getElementById('target-editor-css-source') as HTMLSelectElement).value,
brightness_vs: (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value,
brightness: _brightnessWidget ? JSON.stringify(_brightnessWidget.getValue()) : '1.0',
brightness_threshold: _thresholdWidget ? JSON.stringify(_thresholdWidget.getValue()) : '0',
fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30',
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
@@ -258,7 +259,6 @@ function _updateBrightnessThresholdVisibility() {
// ── EntitySelect instances for target editor ──
let _deviceEntitySelect: EntitySelect | null = null;
let _cssEntitySelect: EntitySelect | null = null;
let _brightnessVsEntitySelect: EntitySelect | null = null;
let _protocolIconSelect: IconSelect | null = null;
function _populateCssDropdown(selectedId = '') {
@@ -268,15 +268,6 @@ function _populateCssDropdown(selectedId = '') {
).join('');
}
function _populateBrightnessVsDropdown(selectedId = '') {
const select = document.getElementById('target-editor-brightness-vs') as HTMLSelectElement;
let html = `<option value="">${t('targets.brightness_vs.none')}</option>`;
_cachedValueSources.forEach(vs => {
html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${escapeHtml(vs.name)}</option>`;
});
select.innerHTML = html;
}
function _ensureTargetEntitySelects() {
// Device
if (_deviceEntitySelect) _deviceEntitySelect.destroy();
@@ -304,20 +295,6 @@ function _ensureTargetEntitySelects() {
placeholder: t('palette.search'),
});
// Brightness value source
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
_brightnessVsEntitySelect = new EntitySelect({
target: document.getElementById('target-editor-brightness-vs') as HTMLSelectElement,
getItems: () => _cachedValueSources.map(vs => ({
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'),
});
}
const _pIcon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -333,6 +310,19 @@ function _ensureProtocolIconSelect() {
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 });
}
function _ensureBrightnessWidget(): BindableScalarWidget {
if (!_brightnessWidget) {
_brightnessWidget = new BindableScalarWidget({
container: document.getElementById('target-editor-brightness-container')!,
min: 0, max: 1, step: 0.05, default: 1.0,
idPrefix: 'target-editor-brightness',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(2),
});
}
return _brightnessWidget;
}
function _ensureFpsWidget(): BindableScalarWidget {
if (!_fpsWidget) {
_fpsWidget = new BindableScalarWidget({
@@ -406,7 +396,7 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
_populateCssDropdown(target.color_strip_source_id || '');
_populateBrightnessVsDropdown(bindableSourceId(target.brightness));
_ensureBrightnessWidget().setValue(target.brightness ?? 1.0);
} else if (cloneData) {
// Cloning — create mode but pre-filled from clone data
_editorTags = cloneData.tags || [];
@@ -424,7 +414,7 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
_populateCssDropdown(cloneData.color_strip_source_id || '');
_populateBrightnessVsDropdown(bindableSourceId(cloneData.brightness));
_ensureBrightnessWidget().setValue(cloneData.brightness ?? 1.0);
} else {
// Creating new target
(document.getElementById('target-editor-id') as HTMLInputElement).value = '';
@@ -440,7 +430,7 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
_populateCssDropdown('');
_populateBrightnessVsDropdown('');
_ensureBrightnessWidget().setValue(1.0);
}
// Entity palette selectors
@@ -454,7 +444,6 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
window._targetAutoName = _autoGenerateTargetName;
deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateSpecificSettingsVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
(document.getElementById('target-editor-css-source') as HTMLSelectElement).onchange = () => { _autoGenerateTargetName(); };
(document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).onchange = () => { _updateBrightnessThresholdVisibility(); };
if (!targetId && !cloneData) _autoGenerateTargetName();
// Show/hide conditional fields
@@ -492,6 +481,7 @@ export async function closeTargetEditorModal() {
export function forceCloseTargetEditorModal() {
if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; }
if (_brightnessWidget) { _brightnessWidget.destroy(); _brightnessWidget = null; }
if (_fpsWidget) { _fpsWidget.destroy(); _fpsWidget = null; }
if (_thresholdWidget) { _thresholdWidget.destroy(); _thresholdWidget = null; }
targetEditorModal.forceClose();
@@ -511,7 +501,7 @@ export async function saveTargetEditor() {
const fps = _fpsWidget ? _fpsWidget.getValue() : 30;
const colorStripSourceId = (document.getElementById('target-editor-css-source') as HTMLSelectElement).value;
const brightnessVsId = (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value;
const brightness = _brightnessWidget ? _brightnessWidget.getValue() : 1.0;
const minBrightnessThreshold = _thresholdWidget ? _thresholdWidget.getValue() : 0;
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
@@ -521,7 +511,7 @@ export async function saveTargetEditor() {
name,
device_id: deviceId,
color_strip_source_id: colorStripSourceId,
brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0,
brightness,
min_brightness_threshold: minBrightnessThreshold,
fps,
keepalive_interval: standbyInterval,
@@ -530,6 +520,8 @@ export async function saveTargetEditor() {
tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
};
payload.target_type = 'led';
try {
let response;
if (targetId) {
@@ -538,7 +530,6 @@ export async function saveTargetEditor() {
body: JSON.stringify(payload),
});
} else {
payload.target_type = 'led';
response = await fetchWithAuth('/output-targets', {
method: 'POST',
body: JSON.stringify(payload),
@@ -651,7 +642,7 @@ export async function loadTargetsTab() {
// Group by type
const ledDevices = devicesWithState;
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
const ledTargets = targetsWithState.filter((t): t is LedOutputTarget & { state?: any; metrics?: any } => t.target_type === 'led' || (t.target_type as string) === 'wled');
const haLightTargets = targetsWithState.filter(t => t.target_type === 'ha_light');
// Update tab badge with running target count
@@ -969,7 +960,7 @@ function _patchTargetMetrics(target: any) {
if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds);
}
export function createTargetCard(target: OutputTarget & { state?: any; metrics?: any }, deviceMap: Record<string, any>, colorStripSourceMap: Record<string, any>, valueSourceMap: Record<string, any>) {
export function createTargetCard(target: LedOutputTarget & { state?: any; metrics?: any }, deviceMap: Record<string, any>, colorStripSourceMap: Record<string, any>, valueSourceMap: Record<string, any>) {
const state = target.state || {};
const isProcessing = state.processing || false;
@@ -1015,7 +1006,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${bindableValue(target.fps, 30)}</span>
<span class="stream-card-prop" title="${t('targets.protocol')}">${_protocolBadge(device, target)}</span>
<span class="stream-card-prop${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</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>` : ''}
${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>` : (bindableValue(target.brightness, 1.0) < 1.0 ? `<span class="stream-card-prop" title="${t('targets.brightness')}">${ICON_SUN} ${Math.round(bindableValue(target.brightness, 1.0) * 100)}%</span>` : '')}
${bindableValue(target.min_brightness_threshold, 0) > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} &lt;${bindableValue(target.min_brightness_threshold, 0)} → off</span>` : ''}
</div>
${renderTagChips(target.tags)}
@@ -10,22 +10,29 @@
* This module manages the editor modal and API operations.
*/
import { _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache } from '../core/state.ts';
import {
_cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache,
_cachedHASources, _cachedColorStripSources, gradientsCache, GradientEntity,
} from '../core/state.ts';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import {
getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon,
getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon, getColorStripIcon, getHAEntityIcon,
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_TRASH,
ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS,
} from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
import type { IconSelectItem } from '../core/icon-select.ts';
import * as P from '../core/icon-paths.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { loadPictureSources } from './streams.ts';
import { hexToRgbArray, rgbArrayToHex } from './css-gradient-editor.ts';
import type { ValueSource } from '../types.ts';
export { getValueSourceIcon };
@@ -33,6 +40,13 @@ export { getValueSourceIcon };
// ── EntitySelect instances for value source editor ──
let _vsAudioSourceEntitySelect: EntitySelect | null = null;
let _vsPictureSourceEntitySelect: EntitySelect | null = null;
let _vsHASourceEntitySelect: EntitySelect | null = null;
let _vsHAEntityEntitySelect: EntitySelect | null = null;
let _vsHAEntities: any[] = [];
let _vsGradientInputEntitySelect: EntitySelect | null = null;
let _vsGradientEntitySelect: EntitySelect | null = null;
let _vsCSSSourceEntitySelect: EntitySelect | null = null;
let _vsGradientEasingIconSelect: IconSelect | null = null;
let _vsTagsInput: TagInput | null = null;
class ValueSourceModal extends Modal {
@@ -40,6 +54,13 @@ class ValueSourceModal extends Modal {
onForceClose() {
if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; }
if (_vsColorEasingIconSelect) { _vsColorEasingIconSelect.destroy(); _vsColorEasingIconSelect = null; }
if (_vsHASourceEntitySelect) { _vsHASourceEntitySelect.destroy(); _vsHASourceEntitySelect = null; }
if (_vsHAEntityEntitySelect) { _vsHAEntityEntitySelect.destroy(); _vsHAEntityEntitySelect = null; }
if (_vsGradientInputEntitySelect) { _vsGradientInputEntitySelect.destroy(); _vsGradientInputEntitySelect = null; }
if (_vsGradientEntitySelect) { _vsGradientEntitySelect.destroy(); _vsGradientEntitySelect = null; }
if (_vsCSSSourceEntitySelect) { _vsCSSSourceEntitySelect.destroy(); _vsCSSSourceEntitySelect = null; }
if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; }
}
snapshotValues() {
@@ -68,6 +89,11 @@ class ValueSourceModal extends Modal {
daylightSpeed: (document.getElementById('value-source-daylight-speed') as HTMLInputElement).value,
daylightRealTime: (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked,
daylightLatitude: (document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value,
staticColor: (document.getElementById('value-source-static-color') as HTMLInputElement).value,
animatedColors: JSON.stringify(_animatedColors),
animatedColorSpeed: (document.getElementById('value-source-animated-color-speed') as HTMLInputElement).value,
animatedColorEasing: (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value,
colorSchedule: JSON.stringify(_colorSchedulePoints),
tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []),
};
}
@@ -101,10 +127,20 @@ function _autoGenerateVSName() {
/* ── Icon-grid type selector ──────────────────────────────────── */
const VS_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight'];
const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity'];
const VS_COLOR_TYPE_KEYS = ['static_color', 'animated_color', 'adaptive_time_color', 'gradient_map', 'css_extract'];
const VS_TYPE_KEYS = [...VS_FLOAT_TYPE_KEYS, ...VS_COLOR_TYPE_KEYS];
function _buildVSTypeItems() {
return VS_TYPE_KEYS.map(key => ({
let _vsTypeFilter: 'all' | 'float' | 'color' = 'all';
function _getFilteredTypeKeys(): string[] {
if (_vsTypeFilter === 'float') return VS_FLOAT_TYPE_KEYS;
if (_vsTypeFilter === 'color') return VS_COLOR_TYPE_KEYS;
return VS_TYPE_KEYS;
}
function _buildVSTypeItems(): IconSelectItem[] {
return _getFilteredTypeKeys().map(key => ({
value: key,
icon: getValueSourceIcon(key),
label: t(`value_source.type.${key}`),
@@ -112,8 +148,11 @@ function _buildVSTypeItems() {
}));
}
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
let _vsTypeIconSelect: IconSelect | null = null;
let _waveformIconSelect: IconSelect | null = null;
let _vsColorEasingIconSelect: IconSelect | null = null;
const _WAVEFORM_SVG = {
sine: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 12 Q15 -4 30 12 Q45 28 60 12"/></svg>',
@@ -135,6 +174,17 @@ function _ensureWaveformIconSelect() {
_waveformIconSelect = new IconSelect({ target: sel, items, columns: 4 } as any);
}
function _ensureColorEasingIconSelect() {
const sel = document.getElementById('value-source-animated-color-easing') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'linear', icon: _icon(P.activity), label: t('value_source.animated_color.easing.linear'), desc: t('value_source.animated_color.easing.linear.desc') },
{ value: 'step', icon: _icon(P.layoutDashboard), label: t('value_source.animated_color.easing.step'), desc: t('value_source.animated_color.easing.step.desc') },
];
if (_vsColorEasingIconSelect) { _vsColorEasingIconSelect.updateItems(items); return; }
_vsColorEasingIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
}
/* ── Waveform canvas preview ──────────────────────────────────── */
/**
@@ -251,10 +301,20 @@ function _ensureVSTypeIconSelect() {
export async function showValueSourceModal(editData: any, presetType: any = null) {
// When creating new: show type picker first, then re-enter with presetType
if (!editData && !presetType) {
_vsTypeFilter = 'all';
showTypePicker({
title: t('value_source.select_type'),
items: _buildVSTypeItems(),
onPick: (type) => showValueSourceModal(null, type),
filterTabs: [
{ key: 'all', label: t('value_source.filter.all') },
{ key: 'float', label: t('value_source.filter.float') },
{ key: 'color', label: t('value_source.filter.color') },
],
onFilterChange: (key) => {
_vsTypeFilter = key as any;
return _buildVSTypeItems();
},
});
return;
}
@@ -322,6 +382,38 @@ export async function showValueSourceModal(editData: any, presetType: any = null
_syncDaylightVSSpeedVisibility();
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
} else if (editData.source_type === 'static_color') {
const rgb = editData.color || [255, 255, 255];
(document.getElementById('value-source-static-color') as HTMLInputElement).value = rgbArrayToHex(rgb);
} else if (editData.source_type === 'animated_color') {
_animatedColors = (editData.colors || [[255, 0, 0], [0, 255, 0], [0, 0, 255]]).map((c: number[]) => rgbArrayToHex(c));
_renderAnimatedColorList();
_setSlider('value-source-animated-color-speed', editData.speed ?? 10.0);
(document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value = editData.easing || 'linear';
_ensureColorEasingIconSelect();
} else if (editData.source_type === 'adaptive_time_color') {
_colorSchedulePoints = (editData.schedule || []).map((p: any) => ({
time: p.time,
color: rgbArrayToHex(p.color || [255, 255, 255]),
}));
_renderColorScheduleList();
} else if (editData.source_type === 'ha_entity') {
_populateHASourceDropdown(editData.ha_source_id || '');
await _fetchVSHAEntities(editData.ha_source_id || '');
_populateHAEntityDropdown(editData.entity_id || '');
(document.getElementById('value-source-attribute') as HTMLInputElement).value = editData.attribute || '';
(document.getElementById('value-source-min-ha-value') as HTMLInputElement).value = String(editData.min_ha_value ?? 0);
(document.getElementById('value-source-max-ha-value') as HTMLInputElement).value = String(editData.max_ha_value ?? 100);
_setSlider('value-source-ha-smoothing', editData.smoothing ?? 0);
} else if (editData.source_type === 'gradient_map') {
_populateGradientInputDropdown(editData.value_source_id || '');
_populateGradientEntityDropdown(editData.gradient_id || '');
(document.getElementById('value-source-gradient-easing') as HTMLSelectElement).value = editData.easing || 'linear';
_ensureGradientEasingIconSelect();
} else if (editData.source_type === 'css_extract') {
_populateCSSSourceDropdown(editData.color_strip_source_id || '');
(document.getElementById('value-source-led-start') as HTMLInputElement).value = String(editData.led_start ?? 0);
(document.getElementById('value-source-led-end') as HTMLInputElement).value = String(editData.led_end ?? -1);
}
} else {
(document.getElementById('value-source-name') as HTMLInputElement).value = '';
@@ -355,6 +447,25 @@ export async function showValueSourceModal(editData: any, presetType: any = null
(document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked = false;
_setSlider('value-source-daylight-latitude', 50);
_syncDaylightVSSpeedVisibility();
// Color type defaults
(document.getElementById('value-source-static-color') as HTMLInputElement).value = '#ffffff';
_animatedColors = ['#ff0000', '#00ff00', '#0000ff'];
_renderAnimatedColorList();
_setSlider('value-source-animated-color-speed', 10.0);
(document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value = 'linear';
_colorSchedulePoints = [];
_renderColorScheduleList();
// HA entity defaults
(document.getElementById('value-source-entity-id') as HTMLInputElement).value = '';
(document.getElementById('value-source-attribute') as HTMLInputElement).value = '';
(document.getElementById('value-source-min-ha-value') as HTMLInputElement).value = '0';
(document.getElementById('value-source-max-ha-value') as HTMLInputElement).value = '100';
_setSlider('value-source-ha-smoothing', 0);
// Gradient map defaults
(document.getElementById('value-source-gradient-easing') as HTMLSelectElement).value = 'linear';
// CSS extract defaults
(document.getElementById('value-source-led-start') as HTMLInputElement).value = '0';
(document.getElementById('value-source-led-end') as HTMLInputElement).value = '-1';
_autoGenerateVSName();
}
@@ -387,6 +498,13 @@ export function onValueSourceTypeChange() {
(document.getElementById('value-source-adaptive-time-section') as HTMLElement).style.display = type === 'adaptive_time' ? '' : 'none';
(document.getElementById('value-source-adaptive-scene-section') as HTMLElement).style.display = type === 'adaptive_scene' ? '' : 'none';
(document.getElementById('value-source-daylight-section') as HTMLElement).style.display = type === 'daylight' ? '' : 'none';
(document.getElementById('value-source-static-color-section') as HTMLElement).style.display = type === 'static_color' ? '' : 'none';
(document.getElementById('value-source-animated-color-section') as HTMLElement).style.display = type === 'animated_color' ? '' : 'none';
if (type === 'animated_color') _ensureColorEasingIconSelect();
(document.getElementById('value-source-adaptive-time-color-section') as HTMLElement).style.display = type === 'adaptive_time_color' ? '' : 'none';
(document.getElementById('value-source-ha-entity-section') as HTMLElement).style.display = type === 'ha_entity' ? '' : 'none';
(document.getElementById('value-source-gradient-map-section') as HTMLElement).style.display = type === 'gradient_map' ? '' : 'none';
(document.getElementById('value-source-css-extract-section') as HTMLElement).style.display = type === 'css_extract' ? '' : 'none';
(document.getElementById('value-source-adaptive-range-section') as HTMLElement).style.display =
(type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none';
@@ -403,6 +521,28 @@ export function onValueSourceTypeChange() {
_populatePictureSourceDropdown('');
}
// Populate HA source dropdown when switching to ha_entity type
if (type === 'ha_entity') {
_populateHASourceDropdown('');
// Auto-fetch entities for the pre-selected HA source
const haSelect = document.getElementById('value-source-ha-source') as HTMLSelectElement;
if (haSelect?.value) {
_fetchVSHAEntities(haSelect.value).then(() => _populateHAEntityDropdown(''));
}
}
// Populate gradient input and gradient entity dropdowns when switching to gradient_map type
if (type === 'gradient_map') {
_populateGradientInputDropdown('');
_populateGradientEntityDropdown('');
_ensureGradientEasingIconSelect();
}
// Populate CSS source dropdown when switching to css_extract type
if (type === 'css_extract') {
_populateCSSSourceDropdown('');
}
_autoGenerateVSName();
}
@@ -471,6 +611,55 @@ export async function saveValueSource() {
payload.latitude = parseFloat((document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value);
payload.min_value = parseFloat((document.getElementById('value-source-adaptive-min-value') as HTMLInputElement).value);
payload.max_value = parseFloat((document.getElementById('value-source-adaptive-max-value') as HTMLInputElement).value);
} else if (sourceType === 'static_color') {
payload.color = hexToRgbArray((document.getElementById('value-source-static-color') as HTMLInputElement).value);
} else if (sourceType === 'animated_color') {
payload.colors = _getAnimatedColorsPayload();
payload.speed = parseFloat((document.getElementById('value-source-animated-color-speed') as HTMLInputElement).value);
payload.easing = (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value;
} else if (sourceType === 'adaptive_time_color') {
payload.schedule = _getColorSchedulePayload();
} else if (sourceType === 'ha_entity') {
payload.ha_source_id = (document.getElementById('value-source-ha-source') as HTMLSelectElement).value;
payload.entity_id = (document.getElementById('value-source-entity-id') as HTMLSelectElement).value;
payload.attribute = (document.getElementById('value-source-attribute') as HTMLInputElement).value.trim();
payload.min_ha_value = parseFloat((document.getElementById('value-source-min-ha-value') as HTMLInputElement).value);
payload.max_ha_value = parseFloat((document.getElementById('value-source-max-ha-value') as HTMLInputElement).value);
payload.smoothing = parseFloat((document.getElementById('value-source-ha-smoothing') as HTMLInputElement).value);
if (!payload.ha_source_id) {
errorEl.textContent = t('value_source.ha_source') + ' required';
errorEl.style.display = '';
return;
}
if (!payload.entity_id) {
errorEl.textContent = t('value_source.entity_id') + ' required';
errorEl.style.display = '';
return;
}
} else if (sourceType === 'gradient_map') {
payload.value_source_id = (document.getElementById('value-source-gradient-input') as HTMLSelectElement).value;
payload.gradient_id = (document.getElementById('value-source-gradient-id') as HTMLSelectElement).value;
payload.easing = (document.getElementById('value-source-gradient-easing') as HTMLSelectElement).value;
if (!payload.value_source_id) {
errorEl.textContent = t('value_source.input_source') + ' required';
errorEl.style.display = '';
return;
}
if (!payload.gradient_id) {
errorEl.textContent = t('value_source.gradient_stops') + ' required';
errorEl.style.display = '';
return;
}
} else if (sourceType === 'css_extract') {
payload.color_strip_source_id = (document.getElementById('value-source-css-source') as HTMLSelectElement).value;
payload.led_start = parseInt((document.getElementById('value-source-led-start') as HTMLInputElement).value) || 0;
payload.led_end = parseInt((document.getElementById('value-source-led-end') as HTMLInputElement).value);
if (isNaN(payload.led_end)) payload.led_end = -1;
if (!payload.color_strip_source_id) {
errorEl.textContent = t('value_source.css_source') + ' required';
errorEl.style.display = '';
return;
}
}
try {
@@ -799,6 +988,54 @@ export function createValueSourceCard(src: ValueSource) {
${psBadge}
<span class="stream-card-prop">${ICON_REFRESH} ${src.scene_behavior || 'complement'}</span>
`;
} else if (src.source_type === 'static_color') {
const rgb = (src as any).color || [255, 255, 255];
const hex = rgbArrayToHex(rgb);
propsHtml = `<span class="stream-card-prop"><span style="display:inline-block;width:12px;height:12px;background:${hex};border:1px solid #888;border-radius:2px;vertical-align:middle"></span> ${hex}</span>`;
} else if (src.source_type === 'animated_color') {
const colors = (src as any).colors || [];
propsHtml = `
<span class="stream-card-prop">${ICON_ACTIVITY} ${colors.length} ${t('value_source.animated_color.color_count')}</span>
<span class="stream-card-prop">${ICON_TIMER} ${(src as any).speed ?? 10} cpm</span>
`;
} else if (src.source_type === 'adaptive_time_color') {
const pts = ((src as any).schedule || []).length;
propsHtml = `<span class="stream-card-prop">${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}</span>`;
} else if (src.source_type === 'ha_entity') {
const haSrc = _cachedHASources.find(h => h.id === (src as any).ha_source_id);
const haName = haSrc ? haSrc.name : ((src as any).ha_source_id || '-');
const entityId = (src as any).entity_id || '';
const attr = (src as any).attribute;
propsHtml = `
<span class="stream-card-prop">${ICON_HOME} ${escapeHtml(haName)}</span>
<span class="stream-card-prop">${ICON_LINK} ${escapeHtml(entityId)}${attr ? '.' + escapeHtml(attr) : ''}</span>
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} ${(src as any).min_ha_value ?? 0}\u2013${(src as any).max_ha_value ?? 100}</span>
`;
} else if (src.source_type === 'gradient_map') {
const inputVs = _cachedValueSources.find(v => v.id === (src as any).value_source_id);
const inputName = inputVs ? inputVs.name : ((src as any).value_source_id || '-');
const gradients = gradientsCache.data || [];
const grad = gradients.find(g => g.id === (src as any).gradient_id);
const gradName = grad ? grad.name : ((src as any).gradient_id || '-');
const stops = grad?.stops || [];
const gradientCss = stops.length >= 2
? `linear-gradient(to right, ${stops.map((s: any) => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ')})`
: '#333';
propsHtml = `
<span class="stream-card-prop">${ICON_LINK} ${escapeHtml(inputName)}</span>
<span class="stream-card-prop">${ICON_RAINBOW} ${escapeHtml(gradName)}</span>
<div style="height:8px;border-radius:4px;margin:4px 0;background:${gradientCss};"></div>
`;
} else if (src.source_type === 'css_extract') {
const cssSrc = _cachedColorStripSources.find(c => c.id === (src as any).color_strip_source_id);
const cssName = cssSrc ? cssSrc.name : ((src as any).color_strip_source_id || '-');
const ledStart = (src as any).led_start ?? 0;
const ledEnd = (src as any).led_end ?? -1;
const rangeLabel = ledEnd < 0 ? `${ledStart}\u2013all` : `${ledStart}\u2013${ledEnd}`;
propsHtml = `
<span class="stream-card-prop">${ICON_DROPLETS} ${escapeHtml(cssName)}</span>
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} LED ${rangeLabel}</span>
`;
}
return wrapCard({
@@ -973,3 +1210,236 @@ function _populateScheduleUI(schedule: any) {
schedule.forEach(p => addSchedulePoint(p.time, p.value));
}
}
// ── Animated Color helpers ──────────────────────────────────
let _animatedColors: string[] = ['#ff0000', '#00ff00', '#0000ff'];
export function addAnimatedColor(color: string = '#ffffff') {
_animatedColors = [..._animatedColors, color];
_renderAnimatedColorList();
}
export function removeAnimatedColor(idx: number) {
_animatedColors = _animatedColors.filter((_, i) => i !== idx);
_renderAnimatedColorList();
}
function _renderAnimatedColorList() {
const list = document.getElementById('value-source-animated-color-list');
if (!list) return;
list.innerHTML = _animatedColors.map((c, i) => `
<div class="schedule-row" style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<input type="color" class="animated-color-input" value="${c}" data-idx="${i}"
onchange="_animatedColors[${i}] = this.value">
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeAnimatedColor(${i})">${ICON_TRASH}</button>
</div>
`).join('');
// Wire up color inputs to update state immutably
list.querySelectorAll('.animated-color-input').forEach((el) => {
const input = el as HTMLInputElement;
const idx = parseInt(input.dataset.idx || '0', 10);
input.addEventListener('input', () => {
_animatedColors = _animatedColors.map((c, i) => i === idx ? input.value : c);
});
});
}
function _getAnimatedColorsPayload(): number[][] {
return _animatedColors.map(c => hexToRgbArray(c));
}
// ── Color Schedule helpers ──────────────────────────────────
let _colorSchedulePoints: { time: string; color: string }[] = [];
export function addColorSchedulePoint(time: string = '12:00', color: string = '#ffffff') {
_colorSchedulePoints = [..._colorSchedulePoints, { time, color }];
_renderColorScheduleList();
}
export function removeColorSchedulePoint(idx: number) {
_colorSchedulePoints = _colorSchedulePoints.filter((_, i) => i !== idx);
_renderColorScheduleList();
}
function _renderColorScheduleList() {
const list = document.getElementById('value-source-color-schedule-list');
if (!list) return;
list.innerHTML = _colorSchedulePoints.map((p, i) => `
<div class="schedule-row" style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<input type="time" class="color-schedule-time" value="${p.time}" data-idx="${i}">
<input type="color" class="color-schedule-color" value="${p.color}" data-idx="${i}">
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeColorSchedulePoint(${i})">${ICON_TRASH}</button>
</div>
`).join('');
list.querySelectorAll('.color-schedule-time').forEach((el) => {
const input = el as HTMLInputElement;
const idx = parseInt(input.dataset.idx || '0', 10);
input.addEventListener('input', () => {
_colorSchedulePoints = _colorSchedulePoints.map((p, i) => i === idx ? { ...p, time: input.value } : p);
});
});
list.querySelectorAll('.color-schedule-color').forEach((el) => {
const input = el as HTMLInputElement;
const idx = parseInt(input.dataset.idx || '0', 10);
input.addEventListener('input', () => {
_colorSchedulePoints = _colorSchedulePoints.map((p, i) => i === idx ? { ...p, color: input.value } : p);
});
});
}
function _getColorSchedulePayload(): { time: string; color: number[] }[] {
return _colorSchedulePoints.map(p => ({ time: p.time, color: hexToRgbArray(p.color) }));
}
// ── HA Entity helpers ──────────────────────────────────────
function _populateHASourceDropdown(selectedId: string) {
const select = document.getElementById('value-source-ha-source') as HTMLSelectElement;
if (!select) return;
select.innerHTML = _cachedHASources.map((s: any) =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
if (_vsHASourceEntitySelect) _vsHASourceEntitySelect.destroy();
if (_cachedHASources.length > 0) {
_vsHASourceEntitySelect = new EntitySelect({
target: select,
getItems: () => _cachedHASources.map(s => ({
value: s.id,
label: s.name,
icon: ICON_HOME,
desc: s.host || '',
})),
placeholder: t('palette.search'),
onChange: (value) => {
_fetchVSHAEntities(value).then(() => _populateHAEntityDropdown(''));
},
});
}
}
async function _fetchVSHAEntities(haSourceId: string): Promise<void> {
if (!haSourceId) { _vsHAEntities = []; return; }
try {
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
if (!resp.ok) { _vsHAEntities = []; return; }
const data = await resp.json();
_vsHAEntities = data.entities || [];
} catch {
_vsHAEntities = [];
}
}
function _populateHAEntityDropdown(selectedId: string) {
const select = document.getElementById('value-source-entity-id') as HTMLSelectElement;
if (!select) return;
select.innerHTML = _vsHAEntities.map((e: any) =>
`<option value="${e.entity_id}"${e.entity_id === selectedId ? ' selected' : ''}>${escapeHtml(e.friendly_name || e.entity_id)}</option>`
).join('');
if (_vsHAEntityEntitySelect) _vsHAEntityEntitySelect.destroy();
if (_vsHAEntities.length > 0) {
_vsHAEntityEntitySelect = new EntitySelect({
target: select,
getItems: () => _vsHAEntities.map((e: any) => ({
value: e.entity_id,
label: e.friendly_name || e.entity_id,
icon: getHAEntityIcon(e),
desc: e.entity_id,
})),
placeholder: t('palette.search'),
});
}
}
// ── Gradient Map helpers ───────────────────────────────────
function _populateGradientInputDropdown(selectedId: string) {
const select = document.getElementById('value-source-gradient-input') as HTMLSelectElement;
if (!select) return;
const floatSources = _cachedValueSources.filter(v => v.return_type === 'float');
select.innerHTML = floatSources.map((s: any) =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
if (_vsGradientInputEntitySelect) _vsGradientInputEntitySelect.destroy();
if (floatSources.length > 0) {
_vsGradientInputEntitySelect = new EntitySelect({
target: select,
getItems: () => floatSources.map(s => ({
value: s.id,
label: s.name,
icon: getValueSourceIcon(s.source_type),
desc: s.source_type,
})),
placeholder: t('palette.search'),
});
}
}
function _ensureGradientEasingIconSelect() {
const sel = document.getElementById('value-source-gradient-easing') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'linear', icon: _icon(P.activity), label: 'Linear', desc: 'Smooth blend' },
{ value: 'step', icon: _icon(P.layoutDashboard), label: 'Step', desc: 'Hard edges' },
];
if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.updateItems(items); return; }
_vsGradientEasingIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
}
function _populateGradientEntityDropdown(selectedId: string) {
const select = document.getElementById('value-source-gradient-id') as HTMLSelectElement;
if (!select) return;
const gradients = gradientsCache.data || [];
select.innerHTML = gradients.map((g: GradientEntity) =>
`<option value="${g.id}"${g.id === selectedId ? ' selected' : ''}>${escapeHtml(g.name)}</option>`
).join('');
if (_vsGradientEntitySelect) _vsGradientEntitySelect.destroy();
if (gradients.length > 0) {
_vsGradientEntitySelect = new EntitySelect({
target: select,
getItems: () => (gradientsCache.data || []).map((g: GradientEntity) => {
const stops = g.stops || [];
const stripHtml = stops.length >= 2
? `<span style="display:inline-block;width:80px;height:16px;border-radius:3px;background:linear-gradient(to right,${stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ')});flex-shrink:0"></span>`
: ICON_RAINBOW;
return {
value: g.id,
label: g.name,
icon: stripHtml,
desc: `${stops.length} stops`,
};
}),
placeholder: t('palette.search'),
});
}
}
// ── CSS Extract helpers ────────────────────────────────────
function _populateCSSSourceDropdown(selectedId: string) {
const select = document.getElementById('value-source-css-source') as HTMLSelectElement;
if (!select) return;
select.innerHTML = _cachedColorStripSources.map((s: any) =>
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
if (_vsCSSSourceEntitySelect) _vsCSSSourceEntitySelect.destroy();
if (_cachedColorStripSources.length > 0) {
_vsCSSSourceEntitySelect = new EntitySelect({
target: select,
getItems: () => _cachedColorStripSources.map(s => ({
value: s.id,
label: s.name,
icon: getColorStripIcon(s.source_type),
desc: s.source_type,
})),
placeholder: t('palette.search'),
});
}
}
+5 -1
View File
@@ -23,7 +23,7 @@ interface Window {
// ─── Visual effects (called from inline <script>) ───
_updateBgAnimAccent: (accent: string) => void;
_updateBgAnimTheme: (dark: boolean) => void;
_updateTabIndicator: () => void;
_updateTabIndicator: (tabName?: string) => void;
// ─── Core / UI ───
toggleHint: (...args: any[]) => any;
@@ -286,6 +286,10 @@ startTargetOverlay: (...args: any[]) => any;
onValueSourceTypeChange: (...args: any[]) => any;
onDaylightVSRealTimeChange: (...args: any[]) => any;
addSchedulePoint: (...args: any[]) => any;
addAnimatedColor: (...args: any[]) => any;
removeAnimatedColor: (...args: any[]) => any;
addColorSchedulePoint: (...args: any[]) => any;
removeColorSchedulePoint: (...args: any[]) => any;
testValueSource: (...args: any[]) => any;
closeTestValueSourceModal: (...args: any[]) => any;
+233 -76
View File
@@ -24,6 +24,25 @@ export function bindableSourceId(b: BindableFloat | undefined): string {
return b.source_id ?? '';
}
// ── Bindable Color ──────────────────────────────────────────
// An RGB color that is either static ([R,G,B] array) or bound to a color value source.
export type BindableColor = number[] | { color: number[]; source_id: string };
/** Extract the static [R,G,B] from a BindableColor. */
export function bindableColor(b: BindableColor | undefined, fallback: number[]): number[] {
if (b === undefined || b === null) return fallback;
if (Array.isArray(b)) return b;
return b.color ?? fallback;
}
/** Extract the source_id from a BindableColor (empty string = not bound). */
export function bindableColorSourceId(b: BindableColor | undefined): string {
if (b === undefined || b === null) return '';
if (Array.isArray(b)) return '';
return b.source_id ?? '';
}
// ── Device ────────────────────────────────────────────────────
export type DeviceType =
@@ -66,7 +85,14 @@ export interface Device {
export type TargetType = 'led' | 'ha_light';
export interface OutputTarget {
export interface HALightMapping {
entity_id: string;
led_start: number;
led_end: number;
brightness_scale: BindableFloat;
}
interface OutputTargetBase {
id: string;
name: string;
target_type: TargetType;
@@ -74,32 +100,34 @@ export interface OutputTarget {
tags: string[];
created_at: string;
updated_at: string;
}
// LED target fields
device_id?: string;
color_strip_source_id?: string;
export interface LedOutputTarget extends OutputTargetBase {
target_type: 'led';
device_id: string;
color_strip_source_id: string;
brightness?: BindableFloat;
fps?: BindableFloat;
keepalive_interval?: number;
state_check_interval?: number;
keepalive_interval: number;
state_check_interval: number;
min_brightness_threshold?: BindableFloat;
adaptive_fps?: boolean;
protocol?: string;
adaptive_fps: boolean;
protocol: string;
}
// HA light target fields
ha_source_id?: string;
export interface HALightOutputTarget extends OutputTargetBase {
target_type: 'ha_light';
ha_source_id: string;
color_strip_source_id: string;
brightness?: BindableFloat;
ha_light_mappings?: HALightMapping[];
update_rate?: BindableFloat;
transition?: BindableFloat;
color_tolerance?: BindableFloat;
min_brightness_threshold?: BindableFloat;
}
export interface HALightMapping {
entity_id: string;
led_start: number;
led_end: number;
brightness_scale: BindableFloat;
}
export type OutputTarget = LedOutputTarget | HALightOutputTarget;
// ── Color Strip Source ────────────────────────────────────────
@@ -188,8 +216,8 @@ export interface ColorStripSource {
interpolation_mode?: string;
calibration?: Calibration;
// Static
color?: number[];
// Static / Effect / Candlelight
color?: BindableColor;
// Gradient
stops?: ColorStop[];
@@ -214,21 +242,21 @@ export interface ColorStripSource {
visualization_mode?: string;
audio_source_id?: string;
sensitivity?: BindableFloat;
color_peak?: number[];
color_peak?: BindableColor;
// Animation
animation?: AnimationConfig;
speed?: BindableFloat;
// API Input
fallback_color?: number[];
fallback_color?: BindableColor;
timeout?: BindableFloat;
interpolation?: string;
// Notification
notification_effect?: string;
duration_ms?: number;
default_color?: string;
default_color?: BindableColor | string;
app_colors?: Record<string, string>;
app_filter_mode?: string;
app_filter_list?: string[];
@@ -280,79 +308,193 @@ export interface PatternTemplate {
export type ValueSourceType =
| 'static' | 'animated' | 'audio'
| 'adaptive_time' | 'adaptive_scene' | 'daylight';
| 'adaptive_time' | 'adaptive_scene' | 'daylight'
| 'static_color' | 'animated_color' | 'adaptive_time_color'
| 'ha_entity' | 'gradient_map' | 'css_extract';
export interface SchedulePoint {
time: string;
value: number;
}
export interface ValueSource {
export interface ColorSchedulePoint {
time: string;
color: number[];
}
interface ValueSourceBase {
id: string;
name: string;
source_type: ValueSourceType;
return_type: 'float' | 'color';
description?: string;
tags: string[];
created_at: string;
updated_at: string;
// Static
value?: number;
// Animated
waveform?: string;
speed?: number;
min_value?: number;
max_value?: number;
// Audio
audio_source_id?: string;
mode?: string;
sensitivity?: number;
smoothing?: number;
auto_gain?: boolean;
// Adaptive
schedule?: SchedulePoint[];
picture_source_id?: string;
scene_behavior?: string;
// Daylight
use_real_time?: boolean;
latitude?: number;
}
export interface StaticValueSource extends ValueSourceBase {
source_type: 'static';
return_type: 'float';
value: number;
}
export interface AnimatedValueSource extends ValueSourceBase {
source_type: 'animated';
return_type: 'float';
waveform: string;
speed: number;
min_value: number;
max_value: number;
}
export interface AudioValueSource extends ValueSourceBase {
source_type: 'audio';
return_type: 'float';
audio_source_id: string;
mode: string;
sensitivity: number;
smoothing: number;
min_value: number;
max_value: number;
auto_gain: boolean;
}
export interface AdaptiveTimeValueSource extends ValueSourceBase {
source_type: 'adaptive_time';
return_type: 'float';
schedule: SchedulePoint[];
min_value: number;
max_value: number;
}
export interface AdaptiveSceneValueSource extends ValueSourceBase {
source_type: 'adaptive_scene';
return_type: 'float';
picture_source_id: string;
scene_behavior: string;
sensitivity: number;
smoothing: number;
min_value: number;
max_value: number;
}
export interface DaylightValueSource extends ValueSourceBase {
source_type: 'daylight';
return_type: 'float';
speed: number;
use_real_time: boolean;
latitude: number;
min_value: number;
max_value: number;
}
export interface StaticColorValueSource extends ValueSourceBase {
source_type: 'static_color';
return_type: 'color';
color: number[];
}
export interface AnimatedColorValueSource extends ValueSourceBase {
source_type: 'animated_color';
return_type: 'color';
colors: number[][];
speed: number;
easing: string;
}
export interface AdaptiveTimeColorValueSource extends ValueSourceBase {
source_type: 'adaptive_time_color';
return_type: 'color';
schedule: ColorSchedulePoint[];
}
export interface HAEntityValueSource extends ValueSourceBase {
source_type: 'ha_entity';
return_type: 'float';
ha_source_id: string;
entity_id: string;
attribute: string;
min_ha_value: number;
max_ha_value: number;
smoothing: number;
}
export interface GradientMapValueSource extends ValueSourceBase {
source_type: 'gradient_map';
return_type: 'color';
value_source_id: string;
gradient_id: string;
easing: string;
}
export interface CSSExtractValueSource extends ValueSourceBase {
source_type: 'css_extract';
return_type: 'color';
color_strip_source_id: string;
led_start: number;
led_end: number;
}
export type ValueSource =
| StaticValueSource
| AnimatedValueSource
| AudioValueSource
| AdaptiveTimeValueSource
| AdaptiveSceneValueSource
| DaylightValueSource
| StaticColorValueSource
| AnimatedColorValueSource
| AdaptiveTimeColorValueSource
| HAEntityValueSource
| GradientMapValueSource
| CSSExtractValueSource;
// ── Audio Source ───────────────────────────────────────────────
export interface AudioSource {
export type AudioSourceType = 'multichannel' | 'mono' | 'band_extract';
interface AudioSourceBase {
id: string;
name: string;
source_type: 'multichannel' | 'mono' | 'band_extract';
source_type: AudioSourceType;
description?: string;
tags: string[];
created_at: string;
updated_at: string;
// Multichannel
device_index?: number;
is_loopback?: boolean;
audio_template_id?: string;
// Mono
audio_source_id?: string;
channel?: string;
// Band Extract
band?: string;
freq_low?: number;
freq_high?: number;
}
export interface MultichannelAudioSource extends AudioSourceBase {
source_type: 'multichannel';
device_index: number;
is_loopback: boolean;
audio_template_id?: string;
}
export interface MonoAudioSource extends AudioSourceBase {
source_type: 'mono';
audio_source_id: string;
channel: string;
}
export interface BandExtractAudioSource extends AudioSourceBase {
source_type: 'band_extract';
audio_source_id: string;
band: string;
freq_low: number;
freq_high: number;
}
export type AudioSource =
| MultichannelAudioSource
| MonoAudioSource
| BandExtractAudioSource;
// ── Picture Source ─────────────────────────────────────────────
export type PictureSourceType = 'raw' | 'processed' | 'static_image' | 'video';
export interface PictureSource {
interface PictureSourceBase {
id: string;
name: string;
stream_type: PictureSourceType;
@@ -360,29 +502,44 @@ export interface PictureSource {
tags: string[];
created_at: string;
updated_at: string;
}
// Raw
display_index?: number;
capture_template_id?: string;
target_fps?: number;
export interface RawPictureSource extends PictureSourceBase {
stream_type: 'raw';
display_index: number;
capture_template_id: string;
target_fps: number;
}
// Processed
source_stream_id?: string;
postprocessing_template_id?: string;
export interface ProcessedPictureSource extends PictureSourceBase {
stream_type: 'processed';
source_stream_id: string;
postprocessing_template_id: string;
}
// Static image
export interface StaticImagePictureSource extends PictureSourceBase {
stream_type: 'static_image';
image_asset_id?: string;
}
// Video
export interface VideoPictureSource extends PictureSourceBase {
stream_type: 'video';
video_asset_id?: string;
loop?: boolean;
playback_speed?: number;
loop: boolean;
playback_speed: number;
start_time?: number;
end_time?: number;
resolution_limit?: number;
clock_id?: string;
target_fps: number;
}
export type PictureSource =
| RawPictureSource
| ProcessedPictureSource
| StaticImagePictureSource
| VideoPictureSource;
// ── Scene Preset ──────────────────────────────────────────────
export interface TargetSnapshot {
@@ -1452,6 +1452,53 @@
"value_source.type.adaptive_scene.desc": "Adjusts by scene content",
"value_source.type.daylight": "Daylight Cycle",
"value_source.type.daylight.desc": "Brightness follows day/night cycle",
"value_source.type.static_color": "Static Color",
"value_source.type.static_color.desc": "Fixed RGB color",
"value_source.type.animated_color": "Animated Color",
"value_source.type.animated_color.desc": "Cycles through colors",
"value_source.type.adaptive_time_color": "Time Color",
"value_source.type.adaptive_time_color.desc": "24-hour color schedule",
"value_source.type.ha_entity": "HA Entity",
"value_source.type.ha_entity.desc": "Reads value from a Home Assistant sensor",
"value_source.type.gradient_map": "Gradient Map",
"value_source.type.gradient_map.desc": "Maps numeric value through a color gradient",
"value_source.type.css_extract": "Strip Extract",
"value_source.type.css_extract.desc": "Extracts color from a color strip source",
"value_source.ha_source": "HA Connection:",
"value_source.ha_source.hint": "Home Assistant connection to read entities from",
"value_source.entity_id": "Entity:",
"value_source.entity_id.hint": "HA entity ID (e.g. sensor.temperature)",
"value_source.attribute": "Attribute (optional):",
"value_source.attribute.hint": "Read a specific attribute instead of the entity state",
"value_source.min_ha_value": "Min HA Value:",
"value_source.min_ha_value.hint": "Raw HA value that maps to 0% output",
"value_source.max_ha_value": "Max HA Value:",
"value_source.max_ha_value.hint": "Raw HA value that maps to 100% output",
"value_source.input_source": "Input Value Source:",
"value_source.input_source.hint": "Float value source (0-1) to map through the gradient",
"value_source.gradient_stops": "Gradient:",
"value_source.gradient_stops.hint": "Color stops for the gradient. Position 0 = input value 0, position 1 = input value 1",
"value_source.easing": "Interpolation:",
"value_source.easing.hint": "How colors blend between stops",
"value_source.css_source": "Color Strip Source:",
"value_source.css_source.hint": "Color strip source to extract color from",
"value_source.led_start": "LED Start:",
"value_source.led_start.hint": "First LED in the range (0-based)",
"value_source.led_end": "LED End:",
"value_source.led_end.hint": "Last LED in the range (-1 = whole strip)",
"value_source.filter.all": "All",
"value_source.filter.float": "Numeric",
"value_source.filter.color": "Color",
"value_source.static_color.color": "Color:",
"value_source.animated_color.colors": "Colors:",
"value_source.animated_color.speed": "Speed (cpm):",
"value_source.animated_color.easing": "Easing:",
"value_source.animated_color.easing.linear": "Linear",
"value_source.animated_color.easing.linear.desc": "Smooth blend between colors",
"value_source.animated_color.easing.step": "Step",
"value_source.animated_color.easing.step.desc": "Instant jump between colors",
"value_source.animated_color.color_count": "colors",
"value_source.adaptive_time_color.schedule": "Color Schedule:",
"value_source.daylight.speed": "Speed:",
"value_source.daylight.speed.hint": "Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.",
"value_source.daylight.use_real_time": "Use Real Time:",
@@ -1530,6 +1577,8 @@
"test.frames": "Frames",
"test.fps": "FPS",
"test.avg_capture": "Avg",
"targets.brightness": "Brightness:",
"targets.brightness.hint": "Output brightness multiplier (01). Can be bound to a value source for dynamic control.",
"targets.brightness_vs": "Brightness Source:",
"targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)",
"targets.brightness_vs.none": "None (device brightness)",
@@ -1440,6 +1440,8 @@
"test.frames": "Кадры",
"test.fps": "Кадр/с",
"test.avg_capture": "Сред",
"targets.brightness": "Яркость:",
"targets.brightness.hint": "Множитель яркости (0–1). Можно привязать к источнику значений для динамического управления.",
"targets.brightness_vs": "Источник яркости:",
"targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)",
"targets.brightness_vs.none": "Нет (яркость устройства)",
@@ -1440,6 +1440,8 @@
"test.frames": "帧数",
"test.fps": "帧率",
"test.avg_capture": "平均",
"targets.brightness": "亮度:",
"targets.brightness.hint": "输出亮度乘数(0–1)。可绑定到值源进行动态控制。",
"targets.brightness_vs": "亮度源:",
"targets.brightness_vs.hint": "可选的值源,每帧动态控制亮度(覆盖设备亮度)",
"targets.brightness_vs.none": "无(设备亮度)",