feat: new value source types (HA entity, gradient map, strip extract) + UI fixes
Lint & Test / test (push) Successful in 1m27s
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:
@@ -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}>▶</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}>▶</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} <${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
@@ -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;
|
||||
|
||||
|
||||
@@ -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 (0–1). 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": "无(设备亮度)",
|
||||
|
||||
Reference in New Issue
Block a user