feat: BindableFloat — universal value source binding for all scalar properties
Lint & Test / test (push) Successful in 1m20s
Lint & Test / test (push) Successful in 1m20s
Introduce BindableFloat abstraction that allows any numeric property to be
either a static value or dynamically driven by a ValueSource. Backward-compatible
serialization: plain float when unbound, {value, source_id} dict when bound.
Backend:
- storage/bindable.py — BindableFloat dataclass + bfloat() helper
- 25+ scalar properties converted across all entity types
- Runtime VS acquisition in ColorStripStreamManager for CSS bindings
- All stream hot loops use self.resolve() for live values
- KeyColorsColorStripStream now inherits ColorStripStream
Frontend:
- BindableScalarWidget (slider + VS picker toggle) for all editors
- TypeScript BindableFloat type + helpers
- Graph editor edges for all bindable properties
- Audio source channel IconSelect grid
Fixes: daylight longitude, candlelight wind_strength/candle_type from_dict
This commit is contained in:
@@ -1109,3 +1109,69 @@ textarea:focus-visible {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── BindableScalarWidget ── */
|
||||
|
||||
.bindable-slider-row,
|
||||
.bindable-vs-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bindable-slider-row input[type="range"] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bindable-vs-row select,
|
||||
.bindable-vs-row .entity-select-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bindable-value {
|
||||
min-width: 3.5ch;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bindable-toggle {
|
||||
flex-shrink: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.bindable-toggle:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.bindable-toggle--active {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--bg-color);
|
||||
}
|
||||
|
||||
.bindable-toggle--active:hover {
|
||||
opacity: 0.85;
|
||||
color: var(--bg-color);
|
||||
}
|
||||
|
||||
.bindable-toggle .icon-xs {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* BindableScalarWidget — a slider that can optionally bind to a value source.
|
||||
*
|
||||
* Renders a slider (range input) with a small toggle button. When toggled to
|
||||
* "bound" mode, shows an EntitySelect value source picker instead of the slider.
|
||||
* Emits a BindableFloat value: plain number (static) or {value, source_id} (bound).
|
||||
*
|
||||
* Usage:
|
||||
* const widget = new BindableScalarWidget({
|
||||
* container: document.getElementById('my-container'),
|
||||
* label: 'Smoothing',
|
||||
* min: 0, max: 1, step: 0.05, default: 0.3,
|
||||
* valueSources: () => cachedValueSources,
|
||||
* onChange: (bf) => { … },
|
||||
* });
|
||||
* widget.setValue({ value: 0.3, source_id: 'vs_abc' });
|
||||
* const bf = widget.getValue(); // BindableFloat
|
||||
*/
|
||||
|
||||
import type { BindableFloat } from '../types.ts';
|
||||
import { bindableValue, bindableSourceId } from '../types.ts';
|
||||
import { EntitySelect } from './entity-palette.ts';
|
||||
import { getValueSourceIcon } from './icons.ts';
|
||||
import { t } from './i18n.ts';
|
||||
|
||||
export interface BindableScalarOpts {
|
||||
container: HTMLElement;
|
||||
label?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
default: number;
|
||||
/** Format the display value (default: 2 decimal places) */
|
||||
format?: (v: number) => string;
|
||||
valueSources: () => Array<{ id: string; name: string; source_type: string }>;
|
||||
onChange?: (value: BindableFloat) => void;
|
||||
/** HTML id prefix for generated elements */
|
||||
idPrefix?: string;
|
||||
/** Label for the "no binding" option (default: generic "None (static value)") */
|
||||
noneLabel?: string;
|
||||
}
|
||||
|
||||
let _widgetCounter = 0;
|
||||
|
||||
export class BindableScalarWidget {
|
||||
private _container: HTMLElement;
|
||||
private _opts: BindableScalarOpts;
|
||||
private _id: string;
|
||||
private _bound: boolean = false;
|
||||
private _staticValue: number;
|
||||
private _sourceId: string = '';
|
||||
private _entitySelect: EntitySelect | null = null;
|
||||
|
||||
// DOM elements
|
||||
private _sliderRow!: HTMLElement;
|
||||
private _vsRow!: HTMLElement;
|
||||
private _slider!: HTMLInputElement;
|
||||
private _display!: HTMLElement;
|
||||
private _toggleBtn!: HTMLButtonElement;
|
||||
private _select!: HTMLSelectElement;
|
||||
|
||||
constructor(opts: BindableScalarOpts) {
|
||||
this._opts = opts;
|
||||
this._container = opts.container;
|
||||
this._staticValue = opts.default;
|
||||
this._id = opts.idPrefix || `bsw-${++_widgetCounter}`;
|
||||
this._render();
|
||||
}
|
||||
|
||||
private _format(v: number): string {
|
||||
return this._opts.format ? this._opts.format(v) : v.toFixed(2);
|
||||
}
|
||||
|
||||
private _render(): void {
|
||||
const { min, max, step } = this._opts;
|
||||
const id = this._id;
|
||||
|
||||
// Toggle button (link icon for binding)
|
||||
const toggleHtml = `<button type="button" class="bindable-toggle" id="${id}-toggle" title="${t('bindable.toggle')}" aria-label="Toggle value source binding">
|
||||
<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>
|
||||
</button>`;
|
||||
|
||||
// Slider row (static mode)
|
||||
const sliderHtml = `<div class="bindable-slider-row" id="${id}-slider-row">
|
||||
<input type="range" id="${id}-slider" min="${min}" max="${max}" step="${step}" value="${this._staticValue}">
|
||||
<span class="bindable-value" id="${id}-display">${this._format(this._staticValue)}</span>
|
||||
${toggleHtml}
|
||||
</div>`;
|
||||
|
||||
// VS picker row (bound mode)
|
||||
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')}" aria-label="Switch to static value">
|
||||
<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>
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
this._container.innerHTML = sliderHtml + vsHtml;
|
||||
|
||||
// Cache DOM refs
|
||||
this._sliderRow = document.getElementById(`${id}-slider-row`)!;
|
||||
this._vsRow = document.getElementById(`${id}-vs-row`)!;
|
||||
this._slider = document.getElementById(`${id}-slider`) as HTMLInputElement;
|
||||
this._display = document.getElementById(`${id}-display`)!;
|
||||
this._select = document.getElementById(`${id}-select`) as HTMLSelectElement;
|
||||
this._toggleBtn = document.getElementById(`${id}-toggle`) as HTMLButtonElement;
|
||||
|
||||
// Slider input handler
|
||||
this._slider.addEventListener('input', () => {
|
||||
this._staticValue = parseFloat(this._slider.value);
|
||||
this._display.textContent = this._format(this._staticValue);
|
||||
this._fireChange();
|
||||
});
|
||||
|
||||
// Toggle to bound mode
|
||||
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._sliderRow.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 {
|
||||
const sources = this._opts.valueSources();
|
||||
const id = this._id;
|
||||
|
||||
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('');
|
||||
|
||||
// Wrap with EntitySelect
|
||||
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: this._opts.noneLabel || t('bindable.none'),
|
||||
onChange: (value: string) => {
|
||||
this._sourceId = value;
|
||||
this._fireChange();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _fireChange(): void {
|
||||
if (this._opts.onChange) {
|
||||
this._opts.onChange(this.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
getValue(): BindableFloat {
|
||||
if (this._bound && this._sourceId) {
|
||||
return { value: this._staticValue, source_id: this._sourceId };
|
||||
}
|
||||
return this._staticValue;
|
||||
}
|
||||
|
||||
setValue(bf: BindableFloat | undefined): void {
|
||||
this._staticValue = bindableValue(bf, this._opts.default);
|
||||
this._sourceId = bindableSourceId(bf);
|
||||
this._bound = !!this._sourceId;
|
||||
|
||||
this._slider.value = String(this._staticValue);
|
||||
this._display.textContent = this._format(this._staticValue);
|
||||
|
||||
this._sliderRow.style.display = this._bound ? 'none' : '';
|
||||
this._vsRow.style.display = this._bound ? '' : 'none';
|
||||
|
||||
if (this._bound) {
|
||||
this._populateVsSelect();
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh the VS dropdown after cache updates. */
|
||||
refresh(): void {
|
||||
if (this._bound) {
|
||||
this._populateVsSelect();
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this._entitySelect) {
|
||||
this._entitySelect.destroy();
|
||||
this._entitySelect = null;
|
||||
}
|
||||
this._container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
@@ -57,20 +57,34 @@ const CONNECTION_MAP: ConnectionEntry[] = [
|
||||
// Output targets
|
||||
{ targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||
{ targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||
{ targetKind: 'output_target', field: 'brightness_value_source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||
{ targetKind: 'output_target', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache, nested: true },
|
||||
{ targetKind: 'output_target', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
|
||||
|
||||
// Automations
|
||||
{ targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
|
||||
{ targetKind: 'automation', field: 'deactivation_scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
|
||||
|
||||
// ── BindableFloat value source edges (CSS properties) ──
|
||||
{ targetKind: 'color_strip_source', field: 'smoothing.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'sensitivity.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'intensity.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'scale.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'speed.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'wind_strength.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'temperature_influence.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'sound_volume.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'timeout.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ 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 },
|
||||
|
||||
// ── Nested fields (not drag-editable in V1) ──
|
||||
{ targetKind: 'color_strip_source', field: 'layer.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'layer.brightness_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'zone.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
|
||||
{ targetKind: 'color_strip_source', field: 'calibration.picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', nested: true },
|
||||
{ targetKind: 'output_target', field: 'settings.pattern_template_id', sourceKind: 'pattern_template', edgeType: 'template', nested: true },
|
||||
{ targetKind: 'output_target', field: 'settings.brightness_value_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'output_target', field: 'settings.brightness.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
|
||||
{ targetKind: 'scene_preset', field: 'target_id', sourceKind: 'output_target', edgeType: 'scene', nested: true },
|
||||
];
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import ELK from 'elkjs/lib/elk.bundled.js';
|
||||
import { bindableSourceId } from '../types.ts';
|
||||
|
||||
/* ── Types ────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -351,18 +352,29 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
|
||||
if (line.picture_source_id) addEdge(line.picture_source_id, s.id, 'calibration.picture_source_id');
|
||||
}
|
||||
}
|
||||
|
||||
// BindableFloat value source edges
|
||||
for (const prop of ['smoothing', 'sensitivity', 'intensity', 'scale', 'speed',
|
||||
'wind_strength', 'temperature_influence', 'sound_volume', 'timeout', 'brightness'] as const) {
|
||||
const vsId = bindableSourceId((s as any)[prop]);
|
||||
if (vsId) addEdge(vsId, s.id, `${prop}.source_id`);
|
||||
}
|
||||
}
|
||||
|
||||
// Output target edges
|
||||
for (const t of e.outputTargets || []) {
|
||||
if (t.device_id) addEdge(t.device_id, t.id, 'device_id');
|
||||
if (t.color_strip_source_id) addEdge(t.color_strip_source_id, t.id, 'color_strip_source_id');
|
||||
if (t.brightness_value_source_id) addEdge(t.brightness_value_source_id, t.id, 'brightness_value_source_id');
|
||||
const bvsId = bindableSourceId(t.brightness);
|
||||
if (bvsId) addEdge(bvsId, t.id, 'brightness.source_id');
|
||||
const transVsId = bindableSourceId(t.transition);
|
||||
if (transVsId) addEdge(transVsId, t.id, 'transition.source_id');
|
||||
if (t.picture_source_id) addEdge(t.picture_source_id, t.id, 'picture_source_id');
|
||||
// KC target settings
|
||||
if (t.settings) {
|
||||
if (t.settings.pattern_template_id) addEdge(t.settings.pattern_template_id, t.id, 'settings.pattern_template_id');
|
||||
if (t.settings.brightness_value_source_id) addEdge(t.settings.brightness_value_source_id, t.id, 'settings.brightness_value_source_id');
|
||||
const settingsBvsId = bindableSourceId(t.settings?.brightness);
|
||||
if (settingsBvsId) addEdge(settingsBvsId, t.id, 'settings.brightness.source_id');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,3 +91,9 @@ export const heart = '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0
|
||||
export const github = '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>';
|
||||
export const home = '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>';
|
||||
export const lock = '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>';
|
||||
export const check = '<path d="M20 6 9 17l-5-5"/>';
|
||||
export const code = '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>';
|
||||
export const doorOpen = '<path d="M13 4h3a2 2 0 0 1 2 2v14"/><path d="M2 20h3"/><path d="M13 20h9"/><path d="M10 12v.01"/><path d="M13 4.562v16.157a1 1 0 0 1-1.242.97L5 20V5.562a2 2 0 0 1 1.515-1.94l4-1A2 2 0 0 1 13 4.561Z"/>';
|
||||
export const toggleRight = '<rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/>';
|
||||
export const droplets = '<path d="M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.26-1.71-3.19S7.29 6.75 7 5.3c-.29 1.45-1.14 2.84-2.29 3.76S3 11.1 3 12.25c0 2.22 1.8 4.05 4 4.05z"/><path d="M12.56 6.6A10.97 10.97 0 0 0 14 3.02c.5 2.5 2 4.9 4 6.5s3 3.5 3 5.5a6.98 6.98 0 0 1-11.91 4.97"/>';
|
||||
export const fan = '<path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/>';
|
||||
|
||||
@@ -91,6 +91,139 @@ export function getAudioEngineIcon(engineType: string): string {
|
||||
return _audioEngineTypeIcons[engineType] || _svg(P.music);
|
||||
}
|
||||
|
||||
// ── MDI → Lucide icon mapping (for Home Assistant entities) ──
|
||||
|
||||
const _mdiMap: Record<string, string> = {
|
||||
'mdi:lightbulb': P.lightbulb,
|
||||
'mdi:lightbulb-group': P.lightbulb,
|
||||
'mdi:lightbulb-outline': P.lightbulb,
|
||||
'mdi:led-strip': P.lightbulb,
|
||||
'mdi:led-strip-variant': P.lightbulb,
|
||||
'mdi:lamp': P.lightbulb,
|
||||
'mdi:ceiling-light': P.lightbulb,
|
||||
'mdi:floor-lamp': P.lightbulb,
|
||||
'mdi:desk-lamp': P.lightbulb,
|
||||
'mdi:wall-sconce': P.lightbulb,
|
||||
'mdi:thermometer': P.thermometer,
|
||||
'mdi:temperature-celsius': P.thermometer,
|
||||
'mdi:temperature-fahrenheit': P.thermometer,
|
||||
'mdi:water-thermometer': P.thermometer,
|
||||
'mdi:home-thermometer': P.thermometer,
|
||||
'mdi:monitor': P.monitor,
|
||||
'mdi:television': P.tv,
|
||||
'mdi:sun-wireless': P.sun,
|
||||
'mdi:weather-sunny': P.sun,
|
||||
'mdi:weather-night': P.moon,
|
||||
'mdi:moon-waning-crescent': P.moon,
|
||||
'mdi:lock': P.lock,
|
||||
'mdi:lock-open': P.lock,
|
||||
'mdi:wifi': P.wifi,
|
||||
'mdi:fire': P.flame,
|
||||
'mdi:power': P.power,
|
||||
'mdi:power-plug': P.plug,
|
||||
'mdi:eye': P.eye,
|
||||
'mdi:home': P.home,
|
||||
'mdi:home-assistant': P.home,
|
||||
'mdi:music': P.music,
|
||||
'mdi:music-note': P.music,
|
||||
'mdi:camera': P.camera,
|
||||
'mdi:flash': P.zap,
|
||||
'mdi:flash-alert': P.zap,
|
||||
'mdi:bell': P.bellRing,
|
||||
'mdi:bell-ring': P.bellRing,
|
||||
'mdi:earth': P.globe,
|
||||
'mdi:web': P.globe,
|
||||
'mdi:clock': P.clock,
|
||||
'mdi:clock-outline': P.clock,
|
||||
'mdi:timer': P.timer,
|
||||
'mdi:timer-outline': P.timer,
|
||||
'mdi:heart': P.heart,
|
||||
'mdi:heart-pulse': P.heart,
|
||||
'mdi:motion-sensor': P.activity,
|
||||
'mdi:run': P.activity,
|
||||
'mdi:walk': P.activity,
|
||||
'mdi:door': P.doorOpen,
|
||||
'mdi:door-open': P.doorOpen,
|
||||
'mdi:door-closed': P.doorOpen,
|
||||
'mdi:window-open': P.doorOpen,
|
||||
'mdi:window-closed': P.doorOpen,
|
||||
'mdi:toggle-switch': P.toggleRight,
|
||||
'mdi:toggle-switch-off': P.toggleRight,
|
||||
'mdi:water': P.droplets,
|
||||
'mdi:water-percent': P.droplets,
|
||||
'mdi:humidity': P.droplets,
|
||||
'mdi:fan': P.fan,
|
||||
'mdi:fan-speed-1': P.fan,
|
||||
'mdi:fan-speed-2': P.fan,
|
||||
'mdi:fan-speed-3': P.fan,
|
||||
'mdi:star': P.star,
|
||||
'mdi:battery': P.zap,
|
||||
'mdi:battery-charging': P.zap,
|
||||
'mdi:gauge': P.activity,
|
||||
'mdi:speedometer': P.activity,
|
||||
'mdi:robot-vacuum': P.settings,
|
||||
'mdi:cog': P.settings,
|
||||
};
|
||||
|
||||
/** Map an MDI icon name (from HA entity) to a Lucide SVG icon. */
|
||||
export function getMdiIcon(mdiName: string): string {
|
||||
if (!mdiName) return _svg(P.listChecks);
|
||||
const path = _mdiMap[mdiName];
|
||||
if (path) return _svg(path);
|
||||
// Fallback: try keyword matching
|
||||
if (mdiName.includes('light')) return _svg(P.lightbulb);
|
||||
if (mdiName.includes('sensor')) return _svg(P.activity);
|
||||
if (mdiName.includes('switch')) return _svg(P.toggleRight);
|
||||
if (mdiName.includes('door') || mdiName.includes('window')) return _svg(P.doorOpen);
|
||||
return _svg(P.listChecks);
|
||||
}
|
||||
|
||||
/** Map HA entity domain to an icon (fallback when no MDI icon is available). */
|
||||
const _domainIcons: Record<string, string> = {
|
||||
light: P.lightbulb,
|
||||
switch: P.toggleRight,
|
||||
sensor: P.activity,
|
||||
binary_sensor: P.toggleRight,
|
||||
climate: P.thermometer,
|
||||
fan: P.fan,
|
||||
cover: P.doorOpen,
|
||||
lock: P.lock,
|
||||
camera: P.camera,
|
||||
media_player: P.music,
|
||||
automation: P.refreshCw,
|
||||
script: P.play,
|
||||
scene: P.star,
|
||||
input_boolean: P.toggleRight,
|
||||
input_number: P.slidersHorizontal,
|
||||
input_select: P.listChecks,
|
||||
input_text: P.fileText,
|
||||
timer: P.timer,
|
||||
counter: P.hash,
|
||||
person: P.smartphone,
|
||||
device_tracker: P.mapPin,
|
||||
zone: P.mapPin,
|
||||
sun: P.sun,
|
||||
weather: P.cloudSun,
|
||||
update: P.download,
|
||||
button: P.power,
|
||||
number: P.slidersHorizontal,
|
||||
select: P.listChecks,
|
||||
text: P.fileText,
|
||||
alarm_control_panel: P.bellRing,
|
||||
water_heater: P.droplets,
|
||||
vacuum: P.settings,
|
||||
humidifier: P.droplets,
|
||||
};
|
||||
|
||||
export function getHAEntityIcon(entity: { icon?: string; domain?: string }): string {
|
||||
// Prefer explicit MDI icon if set
|
||||
if (entity.icon) return getMdiIcon(entity.icon);
|
||||
// Fall back to domain-based icon
|
||||
const domainPath = _domainIcons[entity.domain || ''];
|
||||
if (domainPath) return _svg(domainPath);
|
||||
return _svg(P.listChecks);
|
||||
}
|
||||
|
||||
// ── Entity-kind constants ───────────────────────────────────
|
||||
|
||||
export const ICON_AUTOMATION = _svg(P.clipboardList);
|
||||
|
||||
@@ -30,6 +30,8 @@ class AudioSourceModal extends Modal {
|
||||
|
||||
onForceClose() {
|
||||
if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; }
|
||||
if (_asChannelIconSelect) { _asChannelIconSelect.destroy(); _asChannelIconSelect = null; }
|
||||
if (_asBandIconSelect) { _asBandIconSelect.destroy(); _asBandIconSelect = null; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
@@ -58,6 +60,7 @@ let _asDeviceEntitySelect: EntitySelect | null = null;
|
||||
let _asParentEntitySelect: EntitySelect | null = null;
|
||||
let _asBandParentEntitySelect: EntitySelect | null = null;
|
||||
let _asBandIconSelect: IconSelect | null = null;
|
||||
let _asChannelIconSelect: IconSelect | null = null;
|
||||
|
||||
const _svg = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
@@ -136,7 +139,7 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
|
||||
} else if (editData.source_type === 'mono') {
|
||||
_loadMultichannelSources(editData.audio_source_id);
|
||||
(document.getElementById('audio-source-channel') as HTMLSelectElement).value = editData.channel || 'mono';
|
||||
(document.getElementById('audio-source-channel') as HTMLSelectElement).onchange = () => _autoGenerateAudioSourceName();
|
||||
_ensureChannelIconSelect();
|
||||
} else if (editData.source_type === 'band_extract') {
|
||||
_loadBandParentSources(editData.audio_source_id);
|
||||
(document.getElementById('audio-source-band') as HTMLSelectElement).value = editData.band || 'bass';
|
||||
@@ -155,7 +158,7 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
|
||||
await _loadAudioDevices();
|
||||
} else if (sourceType === 'mono') {
|
||||
_loadMultichannelSources();
|
||||
(document.getElementById('audio-source-channel') as HTMLSelectElement).onchange = () => _autoGenerateAudioSourceName();
|
||||
_ensureChannelIconSelect();
|
||||
} else if (sourceType === 'band_extract') {
|
||||
_loadBandParentSources();
|
||||
(document.getElementById('audio-source-band') as HTMLSelectElement).value = 'bass';
|
||||
@@ -426,6 +429,28 @@ function _ensureBandIconSelect() {
|
||||
});
|
||||
}
|
||||
|
||||
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
function _ensureChannelIconSelect() {
|
||||
const sel = document.getElementById('audio-source-channel') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const items = [
|
||||
{ value: 'mono', icon: _icon(P.headphones), label: t('audio_source.channel.mono'), desc: t('audio_source.channel.mono.desc') },
|
||||
{ value: 'left', icon: _icon(P.volume2), label: t('audio_source.channel.left'), desc: t('audio_source.channel.left.desc') },
|
||||
{ value: 'right', icon: _icon(P.volume2), label: t('audio_source.channel.right'), desc: t('audio_source.channel.right.desc') },
|
||||
];
|
||||
if (_asChannelIconSelect) {
|
||||
_asChannelIconSelect.updateItems(items);
|
||||
return;
|
||||
}
|
||||
_asChannelIconSelect = new IconSelect({
|
||||
target: sel,
|
||||
items,
|
||||
columns: 3,
|
||||
onChange: () => _autoGenerateAudioSourceName(),
|
||||
});
|
||||
}
|
||||
|
||||
function _loadBandParentSources(selectedId?: any) {
|
||||
const select = document.getElementById('audio-source-band-parent') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache, _cachedHASources } from '../core/state.ts';
|
||||
import { getHAEntityIcon } from '../core/icons.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
|
||||
@@ -21,6 +22,33 @@ import { TreeNav } from '../core/tree-nav.ts';
|
||||
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
|
||||
import type { Automation } from '../types.ts';
|
||||
|
||||
// ── HA condition entity cache ──
|
||||
let _haConditionEntities: any[] = [];
|
||||
|
||||
async function _loadHAEntitiesForCondition(haSourceId: string, container: HTMLElement): Promise<void> {
|
||||
if (!haSourceId) { _haConditionEntities = []; return; }
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
|
||||
if (!resp.ok) { _haConditionEntities = []; return; }
|
||||
const data = await resp.json();
|
||||
_haConditionEntities = data.entities || [];
|
||||
} catch {
|
||||
_haConditionEntities = [];
|
||||
}
|
||||
// Rebuild entity select options
|
||||
const entitySelect = container.querySelector('.condition-ha-entity-id') as HTMLSelectElement;
|
||||
if (entitySelect) {
|
||||
const currentVal = entitySelect.value;
|
||||
entitySelect.innerHTML = `<option value="">—</option>` +
|
||||
_haConditionEntities.map((e: any) =>
|
||||
`<option value="${e.entity_id}" ${e.entity_id === currentVal ? 'selected' : ''}>${escapeHtml(e.friendly_name || e.entity_id)}</option>`
|
||||
).join('');
|
||||
if (currentVal && !_haConditionEntities.some((e: any) => e.entity_id === currentVal)) {
|
||||
entitySelect.innerHTML += `<option value="${escapeHtml(currentVal)}" selected>${escapeHtml(currentVal)}</option>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _automationTagsInput: any = null;
|
||||
|
||||
// ── Auto-name ──
|
||||
@@ -732,7 +760,6 @@ function addAutomationConditionRow(condition: any) {
|
||||
const entityId = data.entity_id || '';
|
||||
const haState = data.state || '';
|
||||
const matchMode = data.match_mode || 'exact';
|
||||
// Build HA source options from cached data
|
||||
const haOptions = _cachedHASources.map((s: any) =>
|
||||
`<option value="${s.id}" ${s.id === haSourceId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
@@ -748,7 +775,9 @@ function addAutomationConditionRow(condition: any) {
|
||||
</div>
|
||||
<div class="condition-field">
|
||||
<label>${t('automations.condition.home_assistant.entity_id')}</label>
|
||||
<input type="text" class="condition-ha-entity-id" value="${escapeHtml(entityId)}" placeholder="binary_sensor.front_door">
|
||||
<select class="condition-ha-entity-id">
|
||||
${entityId ? `<option value="${escapeHtml(entityId)}" selected>${escapeHtml(entityId)}</option>` : '<option value="">—</option>'}
|
||||
</select>
|
||||
</div>
|
||||
<div class="condition-field">
|
||||
<label>${t('automations.condition.home_assistant.state')}</label>
|
||||
@@ -763,6 +792,45 @@ function addAutomationConditionRow(condition: any) {
|
||||
</select>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Wire HA source EntitySelect
|
||||
const haSrcSelect = container.querySelector('.condition-ha-source-id') as HTMLSelectElement;
|
||||
new EntitySelect({
|
||||
target: haSrcSelect,
|
||||
getItems: () => _cachedHASources.map((s: any) => ({
|
||||
value: s.id, label: s.name, icon: _icon(P.home),
|
||||
desc: s.connected ? t('ha_source.connected') : t('ha_source.disconnected'),
|
||||
})),
|
||||
placeholder: t('palette.search'),
|
||||
onChange: (newId: string) => _loadHAEntitiesForCondition(newId, container),
|
||||
});
|
||||
|
||||
// Wire entity EntitySelect
|
||||
const entitySelect = container.querySelector('.condition-ha-entity-id') as HTMLSelectElement;
|
||||
const entityES = new EntitySelect({
|
||||
target: entitySelect,
|
||||
getItems: () => _haConditionEntities.map((e: any) => ({
|
||||
value: e.entity_id, label: e.friendly_name || e.entity_id,
|
||||
icon: getHAEntityIcon(e), desc: e.state || '',
|
||||
})),
|
||||
placeholder: t('ha_light.mapping.search_entity'),
|
||||
});
|
||||
|
||||
// Wire match mode IconSelect
|
||||
const matchSelect = container.querySelector('.condition-ha-match-mode') as HTMLSelectElement;
|
||||
new IconSelect({
|
||||
target: matchSelect,
|
||||
items: [
|
||||
{ value: 'exact', icon: _icon(P.check), label: t('automations.condition.mqtt.match_mode.exact'), desc: t('automations.condition.ha.match_mode.exact.desc') },
|
||||
{ value: 'contains', icon: _icon(P.search), label: t('automations.condition.mqtt.match_mode.contains'), desc: t('automations.condition.ha.match_mode.contains.desc') },
|
||||
{ value: 'regex', icon: _icon(P.code), label: t('automations.condition.mqtt.match_mode.regex'), desc: t('automations.condition.ha.match_mode.regex.desc') },
|
||||
],
|
||||
columns: 1,
|
||||
});
|
||||
|
||||
// Load entities if source is already selected
|
||||
if (haSourceId) _loadHAEntitiesForCondition(haSourceId, container);
|
||||
|
||||
return;
|
||||
}
|
||||
if (type === 'webhook') {
|
||||
@@ -878,7 +946,7 @@ function getAutomationEditorConditions() {
|
||||
conditions.push({
|
||||
condition_type: 'home_assistant',
|
||||
ha_source_id: (row.querySelector('.condition-ha-source-id') as HTMLSelectElement).value,
|
||||
entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLInputElement).value.trim(),
|
||||
entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLSelectElement).value.trim(),
|
||||
state: (row.querySelector('.condition-ha-state') as HTMLInputElement).value,
|
||||
match_mode: (row.querySelector('.condition-ha-match-mode') as HTMLSelectElement).value || 'exact',
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
|
||||
|
||||
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
@@ -24,6 +25,7 @@ let _compositeSourceEntitySelects: any[] = [];
|
||||
let _compositeBrightnessEntitySelects: any[] = [];
|
||||
let _compositeBlendIconSelects: any[] = [];
|
||||
let _compositeCSPTEntitySelects: any[] = [];
|
||||
let _compositeOpacityWidgets: BindableScalarWidget[] = [];
|
||||
|
||||
/** Return current composite layers array (for dirty-check snapshot). */
|
||||
export function compositeGetRawLayers() {
|
||||
@@ -47,6 +49,8 @@ export function compositeDestroyEntitySelects() {
|
||||
_compositeBlendIconSelects = [];
|
||||
_compositeCSPTEntitySelects.forEach(es => es.destroy());
|
||||
_compositeCSPTEntitySelects = [];
|
||||
_compositeOpacityWidgets.forEach(w => w.destroy());
|
||||
_compositeOpacityWidgets = [];
|
||||
}
|
||||
|
||||
function _getCompositeBlendItems() {
|
||||
@@ -140,10 +144,8 @@ export function compositeRenderList() {
|
||||
<div class="composite-layer-row">
|
||||
<label class="composite-layer-opacity-label">
|
||||
<span>${t('color_strip.composite.opacity')}:</span>
|
||||
<span class="composite-opacity-val">${parseFloat(layer.opacity).toFixed(2)}</span>
|
||||
</label>
|
||||
<input type="range" class="composite-layer-opacity" data-idx="${i}"
|
||||
min="0" max="1" step="0.05" value="${layer.opacity}">
|
||||
<div class="composite-layer-opacity-container" data-idx="${i}"></div>
|
||||
</div>
|
||||
<div class="composite-layer-row">
|
||||
<label class="composite-layer-brightness-label">
|
||||
@@ -229,14 +231,6 @@ export function compositeRenderList() {
|
||||
});
|
||||
});
|
||||
|
||||
// Wire up live opacity display
|
||||
list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity').forEach(el => {
|
||||
el.addEventListener('input', () => {
|
||||
const val = parseFloat(el.value);
|
||||
(el.closest('.composite-layer-row')!.querySelector('.composite-opacity-val') as HTMLElement).textContent = val.toFixed(2);
|
||||
});
|
||||
});
|
||||
|
||||
// Attach IconSelect to each layer's blend mode dropdown
|
||||
const blendItems = _getCompositeBlendItems();
|
||||
list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend').forEach(sel => {
|
||||
@@ -275,6 +269,19 @@ export function compositeRenderList() {
|
||||
}));
|
||||
});
|
||||
|
||||
// Create BindableScalarWidget for each layer's opacity
|
||||
list.querySelectorAll<HTMLElement>('.composite-layer-opacity-container').forEach((container, i) => {
|
||||
const widget = new BindableScalarWidget({
|
||||
container,
|
||||
min: 0, max: 1, step: 0.05, default: 1.0,
|
||||
idPrefix: `composite-opacity-${i}`,
|
||||
format: (v) => v.toFixed(2),
|
||||
valueSources: () => _cachedValueSources,
|
||||
});
|
||||
widget.setValue(_compositeLayers[i]?.opacity ?? 1.0);
|
||||
_compositeOpacityWidgets.push(widget);
|
||||
});
|
||||
|
||||
_initCompositeLayerDrag(list);
|
||||
}
|
||||
|
||||
@@ -306,7 +313,6 @@ function _compositeLayersSyncFromDom() {
|
||||
if (!list) return;
|
||||
const srcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-source');
|
||||
const blends = list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend');
|
||||
const opacities = list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity');
|
||||
const enableds = list.querySelectorAll<HTMLInputElement>('.composite-layer-enabled');
|
||||
const briSrcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness');
|
||||
const csptSels = list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt');
|
||||
@@ -317,7 +323,7 @@ function _compositeLayersSyncFromDom() {
|
||||
for (let i = 0; i < srcs.length; i++) {
|
||||
_compositeLayers[i].source_id = srcs[i].value;
|
||||
_compositeLayers[i].blend_mode = blends[i].value;
|
||||
_compositeLayers[i].opacity = parseFloat(opacities[i].value);
|
||||
_compositeLayers[i].opacity = _compositeOpacityWidgets[i]?.getValue() ?? _compositeLayers[i].opacity;
|
||||
_compositeLayers[i].enabled = enableds[i].checked;
|
||||
_compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
|
||||
_compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null;
|
||||
|
||||
@@ -13,8 +13,9 @@ import * as P from '../core/icon-paths.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
|
||||
import { _cachedAssets, assetsCache } from '../core/state.ts';
|
||||
import { _cachedAssets, _cachedValueSources, assetsCache } from '../core/state.ts';
|
||||
import { getBaseOrigin } from './settings.ts';
|
||||
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
|
||||
|
||||
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
@@ -36,6 +37,32 @@ export function notificationGetRawAppOverrides() {
|
||||
|
||||
let _notificationEffectIconSelect: any = null;
|
||||
let _notificationFilterModeIconSelect: any = null;
|
||||
let _notificationDurationWidget: BindableScalarWidget | null = null;
|
||||
|
||||
function _ensureNotificationDurationWidget(): BindableScalarWidget {
|
||||
if (!_notificationDurationWidget) {
|
||||
_notificationDurationWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('css-editor-notification-duration-container')!,
|
||||
min: 100, max: 5000, step: 100, default: 1500,
|
||||
idPrefix: 'css-editor-notification-duration',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => String(Math.round(v)),
|
||||
});
|
||||
}
|
||||
return _notificationDurationWidget;
|
||||
}
|
||||
|
||||
export function destroyNotificationDurationWidget(): void {
|
||||
if (_notificationDurationWidget) { _notificationDurationWidget.destroy(); _notificationDurationWidget = null; }
|
||||
}
|
||||
|
||||
export function getNotificationDurationValue(): number | { value: number; source_id: string } {
|
||||
return _notificationDurationWidget ? _notificationDurationWidget.getValue() : 1500;
|
||||
}
|
||||
|
||||
export function getNotificationDurationSnapshot(): string {
|
||||
return _notificationDurationWidget ? JSON.stringify(_notificationDurationWidget.getValue()) : '1500';
|
||||
}
|
||||
|
||||
export function ensureNotificationEffectIconSelect() {
|
||||
const sel = document.getElementById('css-editor-notification-effect') as HTMLSelectElement | null;
|
||||
@@ -346,9 +373,7 @@ export async function loadNotificationState(css: any) {
|
||||
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = !!css.os_listener;
|
||||
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
|
||||
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
|
||||
const dur = css.duration_ms ?? 1500;
|
||||
(document.getElementById('css-editor-notification-duration') as HTMLInputElement).value = dur;
|
||||
(document.getElementById('css-editor-notification-duration-val') as HTMLElement).textContent = dur;
|
||||
_ensureNotificationDurationWidget().setValue(css.duration_ms ?? 1500);
|
||||
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = css.default_color || '#ffffff';
|
||||
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = css.app_filter_mode || 'off';
|
||||
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(css.app_filter_mode || 'off');
|
||||
@@ -380,8 +405,7 @@ export async function resetNotificationState() {
|
||||
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = true;
|
||||
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
|
||||
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
|
||||
(document.getElementById('css-editor-notification-duration') as HTMLInputElement).value = 1500 as any;
|
||||
(document.getElementById('css-editor-notification-duration-val') as HTMLElement).textContent = '1500';
|
||||
_ensureNotificationDurationWidget().setValue(1500);
|
||||
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = '#ffffff';
|
||||
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = 'off';
|
||||
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off');
|
||||
|
||||
@@ -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 } from './color-strips-notification.ts';
|
||||
import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue } from './color-strips-notification.ts';
|
||||
|
||||
/* ── Preview config builder ───────────────────────────────────── */
|
||||
|
||||
@@ -49,7 +49,7 @@ function _collectPreviewConfig() {
|
||||
config = {
|
||||
source_type: 'notification',
|
||||
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
|
||||
duration_ms: parseInt((document.getElementById('css-editor-notification-duration') as HTMLInputElement).value) || 1500,
|
||||
duration_ms: getNotificationDurationValue(),
|
||||
default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
|
||||
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
||||
app_filter_list: filterList,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity } from '../core/state.ts';
|
||||
import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, _cachedValueSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
||||
import { Modal } from '../core/modal.ts';
|
||||
@@ -18,9 +18,11 @@ 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 { 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 { getBaseOrigin } from './settings.ts';
|
||||
import {
|
||||
rgbArrayToHex, hexToRgbArray,
|
||||
@@ -39,6 +41,7 @@ import {
|
||||
notificationGetAppColorsDict, notificationGetAppSoundsDict, notificationGetRawAppOverrides,
|
||||
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||
loadNotificationState, resetNotificationState, showNotificationEndpoint,
|
||||
destroyNotificationDurationWidget, getNotificationDurationValue, getNotificationDurationSnapshot,
|
||||
} from './color-strips-notification.ts';
|
||||
|
||||
// Re-export for app.js window global bindings
|
||||
@@ -58,8 +61,22 @@ class CSSEditorModal extends Modal {
|
||||
|
||||
onForceClose() {
|
||||
if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; }
|
||||
if (_smoothingWidget) { _smoothingWidget.destroy(); _smoothingWidget = null; }
|
||||
if (_audioSensitivityWidget) { _audioSensitivityWidget.destroy(); _audioSensitivityWidget = null; }
|
||||
if (_audioSmoothingWidget) { _audioSmoothingWidget.destroy(); _audioSmoothingWidget = null; }
|
||||
if (_effectIntensityWidget) { _effectIntensityWidget.destroy(); _effectIntensityWidget = null; }
|
||||
if (_effectScaleWidget) { _effectScaleWidget.destroy(); _effectScaleWidget = null; }
|
||||
if (_apiInputTimeoutWidget) { _apiInputTimeoutWidget.destroy(); _apiInputTimeoutWidget = null; }
|
||||
if (_candlelightIntensityWidget) { _candlelightIntensityWidget.destroy(); _candlelightIntensityWidget = null; }
|
||||
if (_candlelightSpeedWidget) { _candlelightSpeedWidget.destroy(); _candlelightSpeedWidget = null; }
|
||||
if (_candlelightWindWidget) { _candlelightWindWidget.destroy(); _candlelightWindWidget = null; }
|
||||
if (_weatherSpeedWidget) { _weatherSpeedWidget.destroy(); _weatherSpeedWidget = null; }
|
||||
if (_weatherTempInfluenceWidget) { _weatherTempInfluenceWidget.destroy(); _weatherTempInfluenceWidget = null; }
|
||||
destroyNotificationDurationWidget();
|
||||
if (_kcPictureSourceEntitySelect) { _kcPictureSourceEntitySelect.destroy(); _kcPictureSourceEntitySelect = null; }
|
||||
if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; }
|
||||
if (_kcSmoothingWidget) { _kcSmoothingWidget.destroy(); _kcSmoothingWidget = null; }
|
||||
if (_kcBrightnessWidget) { _kcBrightnessWidget.destroy(); _kcBrightnessWidget = null; }
|
||||
compositeDestroyEntitySelects();
|
||||
}
|
||||
|
||||
@@ -70,7 +87,7 @@ class CSSEditorModal extends Modal {
|
||||
type,
|
||||
picture_source: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
|
||||
interpolation: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
|
||||
smoothing: (document.getElementById('css-editor-smoothing') as HTMLInputElement).value,
|
||||
smoothing: _smoothingWidget ? JSON.stringify(_smoothingWidget.getValue()) : '0.3',
|
||||
color: (document.getElementById('css-editor-color') as HTMLInputElement).value,
|
||||
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
|
||||
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
|
||||
@@ -79,25 +96,25 @@ class CSSEditorModal extends Modal {
|
||||
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_intensity: (document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value,
|
||||
effect_scale: (document.getElementById('css-editor-effect-scale') as HTMLInputElement).value,
|
||||
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,
|
||||
composite_layers: JSON.stringify(compositeGetRawLayers()),
|
||||
mapped_zones: JSON.stringify(_mappedZones),
|
||||
audio_viz: (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value,
|
||||
audio_source: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value,
|
||||
audio_sensitivity: (document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value,
|
||||
audio_smoothing: (document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value,
|
||||
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_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_timeout: (document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value,
|
||||
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: (document.getElementById('css-editor-notification-duration') as HTMLInputElement).value,
|
||||
notification_duration: getNotificationDurationSnapshot(),
|
||||
notification_default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
|
||||
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,
|
||||
@@ -107,11 +124,13 @@ class CSSEditorModal extends Modal {
|
||||
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_intensity: (document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value,
|
||||
candlelight_intensity: _candlelightIntensityWidget ? JSON.stringify(_candlelightIntensityWidget.getValue()) : '1.0',
|
||||
candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value,
|
||||
candlelight_speed: (document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value,
|
||||
candlelight_speed: _candlelightSpeedWidget ? JSON.stringify(_candlelightSpeedWidget.getValue()) : '1.0',
|
||||
processed_input: (document.getElementById('css-editor-processed-input') as HTMLInputElement).value,
|
||||
processed_template: (document.getElementById('css-editor-processed-template') as HTMLInputElement).value,
|
||||
kc_smoothing: _kcSmoothingWidget ? JSON.stringify(_kcSmoothingWidget.getValue()) : '0.3',
|
||||
kc_brightness: _kcBrightnessWidget ? JSON.stringify(_kcBrightnessWidget.getValue()) : '1.0',
|
||||
kc_rects: JSON.stringify(_kcEditorRects),
|
||||
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
|
||||
};
|
||||
@@ -121,6 +140,17 @@ class CSSEditorModal extends Modal {
|
||||
const cssEditorModal = new CSSEditorModal();
|
||||
|
||||
let _cssTagsInput: any = null;
|
||||
let _smoothingWidget: BindableScalarWidget | null = null;
|
||||
let _audioSensitivityWidget: BindableScalarWidget | null = null;
|
||||
let _audioSmoothingWidget: BindableScalarWidget | null = null;
|
||||
let _effectIntensityWidget: BindableScalarWidget | null = null;
|
||||
let _effectScaleWidget: BindableScalarWidget | null = null;
|
||||
let _apiInputTimeoutWidget: BindableScalarWidget | null = null;
|
||||
let _candlelightIntensityWidget: BindableScalarWidget | null = null;
|
||||
let _candlelightSpeedWidget: BindableScalarWidget | null = null;
|
||||
let _candlelightWindWidget: BindableScalarWidget | null = null;
|
||||
let _weatherSpeedWidget: BindableScalarWidget | null = null;
|
||||
let _weatherTempInfluenceWidget: BindableScalarWidget | null = null;
|
||||
|
||||
// ── EntitySelect instances for CSS editor ──
|
||||
let _cssPictureSourceEntitySelect: any = null;
|
||||
@@ -130,6 +160,8 @@ let _processedInputEntitySelect: any = null;
|
||||
let _processedTemplateEntitySelect: any = null;
|
||||
let _kcPictureSourceEntitySelect: any = null;
|
||||
let _kcInterpolationIconSelect: any = null;
|
||||
let _kcSmoothingWidget: BindableScalarWidget | null = null;
|
||||
let _kcBrightnessWidget: BindableScalarWidget | null = null;
|
||||
|
||||
// ── Key Colors rectangle editor state ──
|
||||
let _kcEditorRects: Array<{ name: string; x: number; y: number; width: number; height: number }> = [];
|
||||
@@ -480,6 +512,175 @@ function _ensureInterpolationIconSelect() {
|
||||
_interpolationIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
||||
}
|
||||
|
||||
function _ensureSmoothingWidget(): BindableScalarWidget {
|
||||
const container = document.getElementById('css-editor-smoothing-container')!;
|
||||
if (!_smoothingWidget) {
|
||||
_smoothingWidget = new BindableScalarWidget({
|
||||
container,
|
||||
min: 0, max: 1, step: 0.05, default: 0.3,
|
||||
idPrefix: 'css-editor-smoothing',
|
||||
valueSources: () => _cachedValueSources,
|
||||
});
|
||||
}
|
||||
return _smoothingWidget;
|
||||
}
|
||||
|
||||
function _ensureKcSmoothingWidget(): BindableScalarWidget {
|
||||
if (!_kcSmoothingWidget) {
|
||||
_kcSmoothingWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('css-editor-kc-smoothing-container')!,
|
||||
min: 0, max: 1, step: 0.05, default: 0.3,
|
||||
idPrefix: 'css-editor-kc-smoothing',
|
||||
format: (v) => v.toFixed(2),
|
||||
valueSources: () => _cachedValueSources,
|
||||
});
|
||||
}
|
||||
return _kcSmoothingWidget;
|
||||
}
|
||||
|
||||
function _ensureKcBrightnessWidget(): BindableScalarWidget {
|
||||
if (!_kcBrightnessWidget) {
|
||||
_kcBrightnessWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('css-editor-kc-brightness-container')!,
|
||||
min: 0, max: 1, step: 0.05, default: 1.0,
|
||||
idPrefix: 'css-editor-kc-brightness',
|
||||
format: (v) => v.toFixed(2),
|
||||
valueSources: () => _cachedValueSources,
|
||||
});
|
||||
}
|
||||
return _kcBrightnessWidget;
|
||||
}
|
||||
|
||||
function _ensureAudioSensitivityWidget(): BindableScalarWidget {
|
||||
if (!_audioSensitivityWidget) {
|
||||
_audioSensitivityWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('css-editor-audio-sensitivity-container')!,
|
||||
min: 0.1, max: 5.0, step: 0.1, default: 1.0,
|
||||
idPrefix: 'css-editor-audio-sensitivity',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(1),
|
||||
});
|
||||
}
|
||||
return _audioSensitivityWidget;
|
||||
}
|
||||
|
||||
function _ensureAudioSmoothingWidget(): BindableScalarWidget {
|
||||
if (!_audioSmoothingWidget) {
|
||||
_audioSmoothingWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('css-editor-audio-smoothing-container')!,
|
||||
min: 0.0, max: 1.0, step: 0.05, default: 0.3,
|
||||
idPrefix: 'css-editor-audio-smoothing',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(2),
|
||||
});
|
||||
}
|
||||
return _audioSmoothingWidget;
|
||||
}
|
||||
|
||||
function _ensureEffectIntensityWidget(): BindableScalarWidget {
|
||||
if (!_effectIntensityWidget) {
|
||||
_effectIntensityWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('css-editor-effect-intensity-container')!,
|
||||
min: 0.1, max: 2.0, step: 0.1, default: 1.0,
|
||||
idPrefix: 'css-editor-effect-intensity',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(1),
|
||||
});
|
||||
}
|
||||
return _effectIntensityWidget;
|
||||
}
|
||||
|
||||
function _ensureEffectScaleWidget(): BindableScalarWidget {
|
||||
if (!_effectScaleWidget) {
|
||||
_effectScaleWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('css-editor-effect-scale-container')!,
|
||||
min: 0.5, max: 5.0, step: 0.1, default: 1.0,
|
||||
idPrefix: 'css-editor-effect-scale',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(1),
|
||||
});
|
||||
}
|
||||
return _effectScaleWidget;
|
||||
}
|
||||
|
||||
function _ensureApiInputTimeoutWidget(): BindableScalarWidget {
|
||||
if (!_apiInputTimeoutWidget) {
|
||||
_apiInputTimeoutWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('css-editor-api-input-timeout-container')!,
|
||||
min: 0, max: 60, step: 0.5, default: 5.0,
|
||||
idPrefix: 'css-editor-api-input-timeout',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(1),
|
||||
});
|
||||
}
|
||||
return _apiInputTimeoutWidget;
|
||||
}
|
||||
|
||||
function _ensureCandlelightIntensityWidget(): BindableScalarWidget {
|
||||
if (!_candlelightIntensityWidget) {
|
||||
_candlelightIntensityWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('css-editor-candlelight-intensity-container')!,
|
||||
min: 0.1, max: 2.0, step: 0.1, default: 1.0,
|
||||
idPrefix: 'css-editor-candlelight-intensity',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(1),
|
||||
});
|
||||
}
|
||||
return _candlelightIntensityWidget;
|
||||
}
|
||||
|
||||
function _ensureCandlelightSpeedWidget(): BindableScalarWidget {
|
||||
if (!_candlelightSpeedWidget) {
|
||||
_candlelightSpeedWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('css-editor-candlelight-speed-container')!,
|
||||
min: 0.1, max: 5.0, step: 0.1, default: 1.0,
|
||||
idPrefix: 'css-editor-candlelight-speed',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(1),
|
||||
});
|
||||
}
|
||||
return _candlelightSpeedWidget;
|
||||
}
|
||||
|
||||
function _ensureCandlelightWindWidget(): BindableScalarWidget {
|
||||
if (!_candlelightWindWidget) {
|
||||
_candlelightWindWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('css-editor-candlelight-wind-container')!,
|
||||
min: 0.0, max: 2.0, step: 0.1, default: 0.0,
|
||||
idPrefix: 'css-editor-candlelight-wind',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(1),
|
||||
});
|
||||
}
|
||||
return _candlelightWindWidget;
|
||||
}
|
||||
|
||||
function _ensureWeatherSpeedWidget(): BindableScalarWidget {
|
||||
if (!_weatherSpeedWidget) {
|
||||
_weatherSpeedWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('css-editor-weather-speed-container')!,
|
||||
min: 0.1, max: 5, step: 0.1, default: 1.0,
|
||||
idPrefix: 'css-editor-weather-speed',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(1),
|
||||
});
|
||||
}
|
||||
return _weatherSpeedWidget;
|
||||
}
|
||||
|
||||
function _ensureWeatherTempInfluenceWidget(): BindableScalarWidget {
|
||||
if (!_weatherTempInfluenceWidget) {
|
||||
_weatherTempInfluenceWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('css-editor-weather-temp-influence-container')!,
|
||||
min: 0, max: 1, step: 0.05, default: 0.5,
|
||||
idPrefix: 'css-editor-weather-temp-influence',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(2),
|
||||
});
|
||||
}
|
||||
return _weatherTempInfluenceWidget;
|
||||
}
|
||||
|
||||
function _ensureApiInputInterpolationIconSelect() {
|
||||
const sel = document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
@@ -992,13 +1193,8 @@ function _loadAudioState(css: any) {
|
||||
if (_audioVizIconSelect) _audioVizIconSelect.setValue(css.visualization_mode || 'spectrum');
|
||||
onAudioVizChange();
|
||||
|
||||
const sensitivity = css.sensitivity ?? 1.0;
|
||||
(document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value = sensitivity;
|
||||
(document.getElementById('css-editor-audio-sensitivity-val') as HTMLElement).textContent = parseFloat(sensitivity).toFixed(1);
|
||||
|
||||
const smoothing = css.smoothing ?? 0.3;
|
||||
(document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value = smoothing;
|
||||
(document.getElementById('css-editor-audio-smoothing-val') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
|
||||
_ensureAudioSensitivityWidget().setValue(css.sensitivity ?? 1.0);
|
||||
_ensureAudioSmoothingWidget().setValue(css.smoothing ?? 0.3);
|
||||
|
||||
const audioGradientId = css.gradient_id || 'gr_builtin_rainbow';
|
||||
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId;
|
||||
@@ -1017,10 +1213,8 @@ function _loadAudioState(css: any) {
|
||||
function _resetAudioState() {
|
||||
(document.getElementById('css-editor-audio-viz') as HTMLInputElement).value = 'spectrum';
|
||||
if (_audioVizIconSelect) _audioVizIconSelect.setValue('spectrum');
|
||||
(document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value = 1.0 as any;
|
||||
(document.getElementById('css-editor-audio-sensitivity-val') as HTMLElement).textContent = '1.0';
|
||||
(document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value = 0.3 as any;
|
||||
(document.getElementById('css-editor-audio-smoothing-val') as HTMLElement).textContent = '0.30';
|
||||
_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';
|
||||
@@ -1127,7 +1321,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
},
|
||||
api_input: (source) => {
|
||||
const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]);
|
||||
const timeoutVal = (source.timeout ?? 5.0).toFixed(1);
|
||||
const timeoutVal = bindableValue(source.timeout, 5.0).toFixed(1);
|
||||
const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear';
|
||||
return `
|
||||
<span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}">
|
||||
@@ -1153,7 +1347,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
},
|
||||
daylight: (source, { clockBadge }) => {
|
||||
const useRealTime = source.use_real_time;
|
||||
const speedVal = (source.speed ?? 1.0).toFixed(1);
|
||||
const speedVal = bindableValue(source.speed, 1.0).toFixed(1);
|
||||
return `
|
||||
<span class="stream-card-prop">${useRealTime ? ICON_CLOCK + ' ' + t('color_strip.daylight.real_time') : ICON_FAST_FORWARD + ' ' + speedVal + 'x'}</span>
|
||||
${clockBadge}
|
||||
@@ -1171,8 +1365,8 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||
`;
|
||||
},
|
||||
weather: (source, { clockBadge }) => {
|
||||
const speedVal = (source.speed ?? 1.0).toFixed(1);
|
||||
const tempInfl = (source.temperature_influence ?? 0.5).toFixed(1);
|
||||
const speedVal = bindableValue(source.speed, 1.0).toFixed(1);
|
||||
const tempInfl = bindableValue(source.temperature_influence, 0.5).toFixed(1);
|
||||
const ws = (_cachedWeatherSources || []).find((w: any) => w.id === source.weather_source_id);
|
||||
const wsName = ws?.name || '—';
|
||||
const wsLink = ws
|
||||
@@ -1420,10 +1614,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
(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]);
|
||||
(document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = css.intensity ?? 1.0;
|
||||
(document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
|
||||
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = css.scale ?? 1.0;
|
||||
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
|
||||
_ensureEffectIntensityWidget().setValue(css.intensity ?? 1.0);
|
||||
_ensureEffectScaleWidget().setValue(css.scale ?? 1.0);
|
||||
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false;
|
||||
onEffectPaletteChange();
|
||||
},
|
||||
@@ -1432,10 +1624,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
(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';
|
||||
(document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = 1.0 as any;
|
||||
(document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = '1.0';
|
||||
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = 1.0 as any;
|
||||
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = '1.0';
|
||||
_ensureEffectIntensityWidget().setValue(1.0);
|
||||
_ensureEffectScaleWidget().setValue(1.0);
|
||||
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false;
|
||||
},
|
||||
getPayload(name) {
|
||||
@@ -1443,8 +1633,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
name,
|
||||
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
|
||||
gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
|
||||
intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value),
|
||||
scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value),
|
||||
intensity: _ensureEffectIntensityWidget().getValue(),
|
||||
scale: _ensureEffectScaleWidget().getValue(),
|
||||
mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
|
||||
};
|
||||
// Meteor/comet/bouncing_ball use a color picker
|
||||
@@ -1468,8 +1658,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
name,
|
||||
visualization_mode: (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value,
|
||||
audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || null,
|
||||
sensitivity: parseFloat((document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value),
|
||||
smoothing: parseFloat((document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value),
|
||||
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),
|
||||
@@ -1519,17 +1709,14 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
load(css) {
|
||||
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value =
|
||||
rgbArrayToHex(css.fallback_color || [0, 0, 0]);
|
||||
(document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value = css.timeout ?? 5.0;
|
||||
(document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent =
|
||||
parseFloat(css.timeout ?? 5.0).toFixed(1);
|
||||
_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';
|
||||
(document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value = 5.0 as any;
|
||||
(document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent = '5.0';
|
||||
_ensureApiInputTimeoutWidget().setValue(5.0);
|
||||
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = 'linear';
|
||||
_ensureApiInputInterpolationIconSelect();
|
||||
_showApiInputEndpoints(null);
|
||||
@@ -1539,7 +1726,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
return {
|
||||
name,
|
||||
fallback_color: hexToRgbArray(fbHex),
|
||||
timeout: parseFloat((document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value),
|
||||
timeout: _ensureApiInputTimeoutWidget().getValue(),
|
||||
interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value,
|
||||
};
|
||||
},
|
||||
@@ -1558,7 +1745,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
name,
|
||||
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: parseInt((document.getElementById('css-editor-notification-duration') as HTMLInputElement).value) || 1500,
|
||||
duration_ms: getNotificationDurationValue(),
|
||||
default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
|
||||
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
|
||||
app_filter_list: filterList,
|
||||
@@ -1602,25 +1789,19 @@ 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]);
|
||||
(document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value = css.intensity ?? 1.0;
|
||||
(document.getElementById('css-editor-candlelight-intensity-val') as HTMLElement).textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
|
||||
_ensureCandlelightIntensityWidget().setValue(css.intensity ?? 1.0);
|
||||
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = css.num_candles ?? 3;
|
||||
(document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value = css.speed ?? 1.0;
|
||||
(document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
|
||||
(document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value = css.wind_strength ?? 0.0;
|
||||
(document.getElementById('css-editor-candlelight-wind-val') as HTMLElement).textContent = parseFloat(css.wind_strength ?? 0.0).toFixed(1);
|
||||
_ensureCandlelightSpeedWidget().setValue(css.speed ?? 1.0);
|
||||
_ensureCandlelightWindWidget().setValue(css.wind_strength ?? 0.0);
|
||||
(document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = css.candle_type || 'default';
|
||||
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue(css.candle_type || 'default');
|
||||
},
|
||||
reset() {
|
||||
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = '#ff9329';
|
||||
(document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value = 1.0 as any;
|
||||
(document.getElementById('css-editor-candlelight-intensity-val') as HTMLElement).textContent = '1.0';
|
||||
_ensureCandlelightIntensityWidget().setValue(1.0);
|
||||
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = 3 as any;
|
||||
(document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value = 1.0 as any;
|
||||
(document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = '1.0';
|
||||
(document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value = 0.0 as any;
|
||||
(document.getElementById('css-editor-candlelight-wind-val') as HTMLElement).textContent = '0.0';
|
||||
_ensureCandlelightSpeedWidget().setValue(1.0);
|
||||
_ensureCandlelightWindWidget().setValue(0.0);
|
||||
(document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = 'default';
|
||||
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue('default');
|
||||
},
|
||||
@@ -1628,10 +1809,10 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
return {
|
||||
name,
|
||||
color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value),
|
||||
intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value),
|
||||
intensity: _ensureCandlelightIntensityWidget().getValue(),
|
||||
num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3,
|
||||
speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value),
|
||||
wind_strength: parseFloat((document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value),
|
||||
speed: _ensureCandlelightSpeedWidget().getValue(),
|
||||
wind_strength: _ensureCandlelightWindWidget().getValue(),
|
||||
candle_type: (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value,
|
||||
};
|
||||
},
|
||||
@@ -1641,19 +1822,15 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
await weatherSourcesCache.fetch();
|
||||
_populateWeatherSourceDropdown();
|
||||
(document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = css.weather_source_id || '';
|
||||
(document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = css.speed ?? 1.0;
|
||||
(document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
|
||||
(document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = css.temperature_influence ?? 0.5;
|
||||
(document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = parseFloat(css.temperature_influence ?? 0.5).toFixed(2);
|
||||
_ensureWeatherSpeedWidget().setValue(css.speed ?? 1.0);
|
||||
_ensureWeatherTempInfluenceWidget().setValue(css.temperature_influence ?? 0.5);
|
||||
},
|
||||
async reset() {
|
||||
await weatherSourcesCache.fetch();
|
||||
_populateWeatherSourceDropdown();
|
||||
(document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = '';
|
||||
(document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = 1.0 as any;
|
||||
(document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = '1.0';
|
||||
(document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = 0.5 as any;
|
||||
(document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = '0.50';
|
||||
_ensureWeatherSpeedWidget().setValue(1.0);
|
||||
_ensureWeatherTempInfluenceWidget().setValue(0.5);
|
||||
},
|
||||
getPayload(name) {
|
||||
const wsId = (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value;
|
||||
@@ -1664,8 +1841,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
return {
|
||||
name,
|
||||
weather_source_id: wsId,
|
||||
speed: parseFloat((document.getElementById('css-editor-weather-speed') as HTMLInputElement).value),
|
||||
temperature_influence: parseFloat((document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value),
|
||||
speed: _ensureWeatherSpeedWidget().getValue(),
|
||||
temperature_influence: _ensureWeatherTempInfluenceWidget().getValue(),
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -1702,21 +1879,18 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
load(css) {
|
||||
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average';
|
||||
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
|
||||
const smoothing = css.smoothing ?? 0.3;
|
||||
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = smoothing;
|
||||
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
|
||||
_ensureSmoothingWidget().setValue(css.smoothing);
|
||||
},
|
||||
reset() {
|
||||
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average';
|
||||
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
|
||||
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = 0.3 as any;
|
||||
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = '0.30';
|
||||
_ensureSmoothingWidget().setValue(0.3);
|
||||
},
|
||||
getPayload(name) {
|
||||
return {
|
||||
name,
|
||||
interpolation_mode: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
|
||||
smoothing: parseFloat((document.getElementById('css-editor-smoothing') as HTMLInputElement).value),
|
||||
smoothing: _ensureSmoothingWidget().getValue(),
|
||||
led_count: parseInt((document.getElementById('css-editor-led-count') as HTMLInputElement).value) || 0,
|
||||
};
|
||||
},
|
||||
@@ -1726,22 +1900,19 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
sourceSelect.value = css.picture_source_id || '';
|
||||
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average';
|
||||
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
|
||||
const smoothing = css.smoothing ?? 0.3;
|
||||
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = smoothing;
|
||||
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
|
||||
_ensureSmoothingWidget().setValue(css.smoothing);
|
||||
},
|
||||
reset() {
|
||||
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average';
|
||||
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
|
||||
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = 0.3 as any;
|
||||
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = '0.30';
|
||||
_ensureSmoothingWidget().setValue(0.3);
|
||||
},
|
||||
getPayload(name) {
|
||||
return {
|
||||
name,
|
||||
picture_source_id: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
|
||||
interpolation_mode: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
|
||||
smoothing: parseFloat((document.getElementById('css-editor-smoothing') as HTMLInputElement).value),
|
||||
smoothing: _ensureSmoothingWidget().getValue(),
|
||||
led_count: parseInt((document.getElementById('css-editor-led-count') as HTMLInputElement).value) || 0,
|
||||
};
|
||||
},
|
||||
@@ -1775,11 +1946,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
columns: 1,
|
||||
});
|
||||
|
||||
const smoothing = css.smoothing ?? 0.3;
|
||||
(document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = smoothing;
|
||||
(document.getElementById('css-editor-kc-smoothing-val') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
|
||||
(document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value = css.brightness ?? 1.0;
|
||||
(document.getElementById('css-editor-kc-brightness-val') as HTMLElement).textContent = parseFloat(css.brightness ?? 1.0).toFixed(2);
|
||||
_ensureKcSmoothingWidget().setValue(css.smoothing ?? 0.3);
|
||||
_ensureKcBrightnessWidget().setValue(css.brightness ?? 1.0);
|
||||
// Load rectangles
|
||||
_kcEditorRects = (css.rectangles || []).map((r: any) => ({ ...r }));
|
||||
_renderKCRectSummary();
|
||||
@@ -1810,10 +1978,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
columns: 1,
|
||||
});
|
||||
|
||||
(document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = 0.3 as any;
|
||||
(document.getElementById('css-editor-kc-smoothing-val') as HTMLElement).textContent = '0.30';
|
||||
(document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value = 1.0 as any;
|
||||
(document.getElementById('css-editor-kc-brightness-val') as HTMLElement).textContent = '1.00';
|
||||
_ensureKcSmoothingWidget().setValue(0.3);
|
||||
_ensureKcBrightnessWidget().setValue(1.0);
|
||||
_kcEditorRects = [{ name: 'Region 1', x: 0.0, y: 0.0, width: 1.0, height: 1.0 }];
|
||||
_renderKCRectSummary();
|
||||
},
|
||||
@@ -1832,8 +1998,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
||||
picture_source_id: psId,
|
||||
rectangles: _kcEditorRects.map(r => ({ name: r.name, x: r.x, y: r.y, width: r.width, height: r.height })),
|
||||
interpolation_mode: (document.getElementById('css-editor-kc-interpolation') as HTMLSelectElement).value,
|
||||
smoothing: parseFloat((document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value),
|
||||
brightness: parseFloat((document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value),
|
||||
smoothing: _ensureKcSmoothingWidget().getValue(),
|
||||
brightness: _ensureKcBrightnessWidget().getValue(),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,11 +7,13 @@ 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 } from '../core/icons.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 * as P from '../core/icon-paths.ts';
|
||||
import { EntitySelect } from '../core/entity-palette.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { bindableSourceId, bindableValue } from '../types.ts';
|
||||
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
|
||||
|
||||
const ICON_HA = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
|
||||
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
@@ -25,6 +27,10 @@ let _brightnessVsEntitySelect: EntitySelect | null = null;
|
||||
let _mappingEntitySelects: EntitySelect[] = [];
|
||||
let _editorCssSources: any[] = [];
|
||||
let _cachedHAEntities: any[] = []; // fetched from selected HA source
|
||||
let _updateRateWidget: BindableScalarWidget | null = null;
|
||||
let _transitionWidget: BindableScalarWidget | null = null;
|
||||
let _colorToleranceWidget: BindableScalarWidget | null = null;
|
||||
let _minBrightnessThresholdWidget: BindableScalarWidget | null = null;
|
||||
|
||||
class HALightEditorModal extends Modal {
|
||||
constructor() { super('ha-light-editor-modal'); }
|
||||
@@ -34,6 +40,10 @@ class HALightEditorModal extends Modal {
|
||||
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
|
||||
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
|
||||
if (_brightnessVsEntitySelect) { _brightnessVsEntitySelect.destroy(); _brightnessVsEntitySelect = null; }
|
||||
if (_updateRateWidget) { _updateRateWidget.destroy(); _updateRateWidget = null; }
|
||||
if (_transitionWidget) { _transitionWidget.destroy(); _transitionWidget = null; }
|
||||
if (_colorToleranceWidget) { _colorToleranceWidget.destroy(); _colorToleranceWidget = null; }
|
||||
if (_minBrightnessThresholdWidget) { _minBrightnessThresholdWidget.destroy(); _minBrightnessThresholdWidget = null; }
|
||||
_destroyMappingEntitySelects();
|
||||
}
|
||||
|
||||
@@ -42,8 +52,10 @@ 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,
|
||||
update_rate: (document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value,
|
||||
transition: (document.getElementById('ha-light-editor-transition') as HTMLInputElement).value,
|
||||
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',
|
||||
min_brightness_threshold: _minBrightnessThresholdWidget ? JSON.stringify(_minBrightnessThresholdWidget.getValue()) : '0',
|
||||
mappings: _getMappingsJSON(),
|
||||
tags: JSON.stringify(_haLightTagsInput ? _haLightTagsInput.getValue() : []),
|
||||
};
|
||||
@@ -77,7 +89,7 @@ function _getEntityItems() {
|
||||
.map((e: any) => ({
|
||||
value: e.entity_id,
|
||||
label: e.friendly_name || e.entity_id,
|
||||
icon: _icon(P.lightbulb),
|
||||
icon: getHAEntityIcon(e),
|
||||
desc: e.state || '',
|
||||
}));
|
||||
}
|
||||
@@ -201,6 +213,60 @@ export function removeHALightMapping(btn: HTMLElement): void {
|
||||
row.remove();
|
||||
}
|
||||
|
||||
// ── Bindable scalar widgets ──
|
||||
|
||||
function _ensureUpdateRateWidget(): BindableScalarWidget {
|
||||
if (!_updateRateWidget) {
|
||||
_updateRateWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('ha-light-editor-update-rate-container')!,
|
||||
min: 0.5, max: 5.0, step: 0.1, default: 2.0,
|
||||
idPrefix: 'ha-light-editor-update-rate',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(1),
|
||||
});
|
||||
}
|
||||
return _updateRateWidget;
|
||||
}
|
||||
|
||||
function _ensureTransitionWidget(): BindableScalarWidget {
|
||||
if (!_transitionWidget) {
|
||||
_transitionWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('ha-light-editor-transition-container')!,
|
||||
min: 0.0, max: 10.0, step: 0.1, default: 0.5,
|
||||
idPrefix: 'ha-light-editor-transition',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => v.toFixed(1),
|
||||
});
|
||||
}
|
||||
return _transitionWidget;
|
||||
}
|
||||
|
||||
function _ensureColorToleranceWidget(): BindableScalarWidget {
|
||||
if (!_colorToleranceWidget) {
|
||||
_colorToleranceWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('ha-light-editor-color-tolerance-container')!,
|
||||
min: 0, max: 50, step: 1, default: 5,
|
||||
idPrefix: 'ha-light-editor-color-tolerance',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => String(Math.round(v)),
|
||||
});
|
||||
}
|
||||
return _colorToleranceWidget;
|
||||
}
|
||||
|
||||
function _ensureMinBrightnessThresholdWidget(): BindableScalarWidget {
|
||||
if (!_minBrightnessThresholdWidget) {
|
||||
_minBrightnessThresholdWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('ha-light-editor-min-brightness-threshold-container')!,
|
||||
min: 0, max: 254, step: 1, default: 0,
|
||||
idPrefix: 'ha-light-editor-min-brightness-threshold',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => String(Math.round(v)),
|
||||
});
|
||||
}
|
||||
return _minBrightnessThresholdWidget;
|
||||
}
|
||||
|
||||
// ── Show / Close ──
|
||||
|
||||
export async function showHALightEditor(targetId: string | null = null, cloneData: any = null): Promise<void> {
|
||||
@@ -256,10 +322,10 @@ 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 || '';
|
||||
(document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value = String(editData.update_rate ?? 2.0);
|
||||
document.getElementById('ha-light-editor-update-rate-display')!.textContent = (editData.update_rate ?? 2.0).toFixed(1);
|
||||
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = String(editData.ha_transition ?? 0.5);
|
||||
document.getElementById('ha-light-editor-transition-display')!.textContent = (editData.ha_transition ?? 0.5).toFixed(1);
|
||||
_ensureUpdateRateWidget().setValue(editData.update_rate ?? 2.0);
|
||||
_ensureTransitionWidget().setValue(editData.transition ?? 0.5);
|
||||
_ensureColorToleranceWidget().setValue(editData.color_tolerance ?? 5);
|
||||
_ensureMinBrightnessThresholdWidget().setValue(editData.min_brightness_threshold ?? 0);
|
||||
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = editData.description || '';
|
||||
|
||||
// Fetch entities from the selected HA source before loading mappings
|
||||
@@ -270,10 +336,10 @@ 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 = '';
|
||||
(document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value = '2.0';
|
||||
document.getElementById('ha-light-editor-update-rate-display')!.textContent = '2.0';
|
||||
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = '0.5';
|
||||
document.getElementById('ha-light-editor-transition-display')!.textContent = '0.5';
|
||||
_ensureUpdateRateWidget().setValue(2.0);
|
||||
_ensureTransitionWidget().setValue(0.5);
|
||||
_ensureColorToleranceWidget().setValue(5);
|
||||
_ensureMinBrightnessThresholdWidget().setValue(0);
|
||||
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = '';
|
||||
|
||||
// Fetch entities from the first HA source
|
||||
@@ -313,7 +379,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
||||
const bvsSelect = document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement;
|
||||
bvsSelect.innerHTML = `<option value="">${t('targets.brightness_vs.none')}</option>` +
|
||||
_cachedValueSources.map((vs: any) =>
|
||||
`<option value="${vs.id}" ${vs.id === (editData?.brightness_value_source_id || '') ? 'selected' : ''}>${escapeHtml(vs.name)}</option>`
|
||||
`<option value="${vs.id}" ${vs.id === bindableSourceId(editData?.brightness) ? 'selected' : ''}>${escapeHtml(vs.name)}</option>`
|
||||
).join('');
|
||||
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
|
||||
_brightnessVsEntitySelect = new EntitySelect({
|
||||
@@ -346,9 +412,10 @@ export async function saveHALightEditor(): Promise<void> {
|
||||
const name = (document.getElementById('ha-light-editor-name') as HTMLInputElement).value.trim();
|
||||
const haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value;
|
||||
const cssSourceId = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value;
|
||||
const updateRate = parseFloat((document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value) || 2.0;
|
||||
const transitionRaw = parseFloat((document.getElementById('ha-light-editor-transition') as HTMLInputElement).value);
|
||||
const transition = isNaN(transitionRaw) ? 0.5 : transitionRaw;
|
||||
const updateRate = _updateRateWidget ? _updateRateWidget.getValue() : 2.0;
|
||||
const transition = _transitionWidget ? _transitionWidget.getValue() : 0.5;
|
||||
const colorTolerance = _colorToleranceWidget ? _colorToleranceWidget.getValue() : 5;
|
||||
const minBrightnessThreshold = _minBrightnessThresholdWidget ? _minBrightnessThresholdWidget.getValue() : 0;
|
||||
const description = (document.getElementById('ha-light-editor-description') as HTMLInputElement).value.trim() || null;
|
||||
|
||||
if (!name) {
|
||||
@@ -369,10 +436,12 @@ export async function saveHALightEditor(): Promise<void> {
|
||||
name,
|
||||
ha_source_id: haSourceId,
|
||||
color_strip_source_id: cssSourceId,
|
||||
brightness_value_source_id: brightnessVsId,
|
||||
brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0,
|
||||
ha_light_mappings: mappings,
|
||||
update_rate: updateRate,
|
||||
transition,
|
||||
color_tolerance: colorTolerance,
|
||||
min_brightness_threshold: minBrightnessThreshold,
|
||||
description,
|
||||
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
|
||||
};
|
||||
@@ -450,7 +519,7 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
|
||||
: '';
|
||||
|
||||
// Brightness value source
|
||||
const bvsId = target.brightness_value_source_id || '';
|
||||
const bvsId = bindableSourceId(target.brightness);
|
||||
const bvs = bvsId && valueSourceMap[bvsId] ? valueSourceMap[bvsId] : null;
|
||||
|
||||
return wrapCard({
|
||||
|
||||
@@ -36,6 +36,8 @@ 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 { bindableSourceId, bindableValue } from '../types.ts';
|
||||
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
|
||||
|
||||
// createPatternTemplateCard is imported via window.* to avoid circular deps
|
||||
// (pattern-templates.js calls window.loadTargetsTab)
|
||||
@@ -142,6 +144,8 @@ function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
|
||||
// --- Editor state ---
|
||||
let _editorCssSources: any[] = []; // populated when editor opens
|
||||
let _targetTagsInput: TagInput | null = null;
|
||||
let _fpsWidget: BindableScalarWidget | null = null;
|
||||
let _thresholdWidget: BindableScalarWidget | null = null;
|
||||
|
||||
class TargetEditorModal extends Modal {
|
||||
constructor() {
|
||||
@@ -155,8 +159,8 @@ class TargetEditorModal extends Modal {
|
||||
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_threshold: (document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value,
|
||||
fps: (document.getElementById('target-editor-fps') as HTMLInputElement).value,
|
||||
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,
|
||||
adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked,
|
||||
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
|
||||
@@ -329,6 +333,32 @@ function _ensureProtocolIconSelect() {
|
||||
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 });
|
||||
}
|
||||
|
||||
function _ensureFpsWidget(): BindableScalarWidget {
|
||||
if (!_fpsWidget) {
|
||||
_fpsWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('target-editor-fps-container')!,
|
||||
min: 1, max: 90, step: 1, default: 30,
|
||||
idPrefix: 'target-editor-fps',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => String(Math.round(v)),
|
||||
});
|
||||
}
|
||||
return _fpsWidget;
|
||||
}
|
||||
|
||||
function _ensureThresholdWidget(): BindableScalarWidget {
|
||||
if (!_thresholdWidget) {
|
||||
_thresholdWidget = new BindableScalarWidget({
|
||||
container: document.getElementById('target-editor-brightness-threshold-container')!,
|
||||
min: 0, max: 254, step: 1, default: 0,
|
||||
idPrefix: 'target-editor-brightness-threshold',
|
||||
valueSources: () => _cachedValueSources,
|
||||
format: (v) => String(Math.round(v)),
|
||||
});
|
||||
}
|
||||
return _thresholdWidget;
|
||||
}
|
||||
|
||||
export async function showTargetEditor(targetId: string | null = null, cloneData: any = null) {
|
||||
try {
|
||||
// Load devices, CSS sources, and value sources for dropdowns
|
||||
@@ -365,56 +395,46 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
|
||||
(document.getElementById('target-editor-id') as HTMLInputElement).value = target.id;
|
||||
(document.getElementById('target-editor-name') as HTMLInputElement).value = target.name;
|
||||
deviceSelect.value = target.device_id || '';
|
||||
const fps = target.fps ?? 30;
|
||||
(document.getElementById('target-editor-fps') as HTMLInputElement).value = fps;
|
||||
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps;
|
||||
_ensureFpsWidget().setValue(target.fps ?? 30);
|
||||
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = target.keepalive_interval ?? 1.0;
|
||||
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = target.keepalive_interval ?? 1.0;
|
||||
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.edit')}`;
|
||||
|
||||
const thresh = target.min_brightness_threshold ?? 0;
|
||||
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = thresh;
|
||||
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = thresh;
|
||||
_ensureThresholdWidget().setValue(target.min_brightness_threshold ?? 0);
|
||||
|
||||
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false;
|
||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
|
||||
|
||||
_populateCssDropdown(target.color_strip_source_id || '');
|
||||
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
|
||||
_populateBrightnessVsDropdown(bindableSourceId(target.brightness));
|
||||
} else if (cloneData) {
|
||||
// Cloning — create mode but pre-filled from clone data
|
||||
_editorTags = cloneData.tags || [];
|
||||
(document.getElementById('target-editor-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('target-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
|
||||
deviceSelect.value = cloneData.device_id || '';
|
||||
const fps = cloneData.fps ?? 30;
|
||||
(document.getElementById('target-editor-fps') as HTMLInputElement).value = fps;
|
||||
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps;
|
||||
_ensureFpsWidget().setValue(cloneData.fps ?? 30);
|
||||
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = cloneData.keepalive_interval ?? 1.0;
|
||||
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = cloneData.keepalive_interval ?? 1.0;
|
||||
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
|
||||
|
||||
const cloneThresh = cloneData.min_brightness_threshold ?? 0;
|
||||
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = cloneThresh;
|
||||
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = cloneThresh;
|
||||
_ensureThresholdWidget().setValue(cloneData.min_brightness_threshold ?? 0);
|
||||
|
||||
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false;
|
||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
|
||||
|
||||
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
||||
_populateBrightnessVsDropdown(cloneData.brightness_value_source_id || '');
|
||||
_populateBrightnessVsDropdown(bindableSourceId(cloneData.brightness));
|
||||
} else {
|
||||
// Creating new target
|
||||
(document.getElementById('target-editor-id') as HTMLInputElement).value = '';
|
||||
(document.getElementById('target-editor-name') as HTMLInputElement).value = '';
|
||||
(document.getElementById('target-editor-fps') as HTMLInputElement).value = 30 as any;
|
||||
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = '30';
|
||||
_ensureFpsWidget().setValue(30);
|
||||
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = 1.0 as any;
|
||||
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = '1.0';
|
||||
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
|
||||
|
||||
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = 0 as any;
|
||||
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = '0';
|
||||
_ensureThresholdWidget().setValue(0);
|
||||
|
||||
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false;
|
||||
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
|
||||
@@ -472,6 +492,8 @@ export async function closeTargetEditorModal() {
|
||||
|
||||
export function forceCloseTargetEditorModal() {
|
||||
if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; }
|
||||
if (_fpsWidget) { _fpsWidget.destroy(); _fpsWidget = null; }
|
||||
if (_thresholdWidget) { _thresholdWidget.destroy(); _thresholdWidget = null; }
|
||||
targetEditorModal.forceClose();
|
||||
}
|
||||
|
||||
@@ -486,11 +508,11 @@ export async function saveTargetEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
const fps = parseInt((document.getElementById('target-editor-fps') as HTMLInputElement).value) || 30;
|
||||
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 minBrightnessThreshold = parseInt((document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value) || 0;
|
||||
const minBrightnessThreshold = _thresholdWidget ? _thresholdWidget.getValue() : 0;
|
||||
|
||||
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
|
||||
const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value;
|
||||
@@ -499,7 +521,7 @@ export async function saveTargetEditor() {
|
||||
name,
|
||||
device_id: deviceId,
|
||||
color_strip_source_id: colorStripSourceId,
|
||||
brightness_value_source_id: brightnessVsId,
|
||||
brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0,
|
||||
min_brightness_threshold: minBrightnessThreshold,
|
||||
fps,
|
||||
keepalive_interval: standbyInterval,
|
||||
@@ -958,7 +980,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
|
||||
const cssId = target.color_strip_source_id || '';
|
||||
const cssSummary = _cssSourceName(cssId, colorStripSourceMap);
|
||||
|
||||
const bvsId = target.brightness_value_source_id || '';
|
||||
const bvsId = bindableSourceId(target.brightness);
|
||||
const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null;
|
||||
|
||||
// Determine if overlay is available (picture-based CSS)
|
||||
@@ -990,11 +1012,11 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led-devices','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span>
|
||||
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${target.fps || 30}</span>
|
||||
<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>` : ''}
|
||||
${(target.min_brightness_threshold ?? 0) > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} <${target.min_brightness_threshold} → off</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)}
|
||||
<div class="card-content">
|
||||
|
||||
@@ -5,6 +5,25 @@
|
||||
* snake_case to match the JSON payloads — no camelCase transformation is done.
|
||||
*/
|
||||
|
||||
// ── Bindable Float ───────────────────────────────────────────
|
||||
// A scalar that is either a static value (plain number) or bound to a value source (dict).
|
||||
|
||||
export type BindableFloat = number | { value: number; source_id: string };
|
||||
|
||||
/** Extract the static value from a BindableFloat. */
|
||||
export function bindableValue(b: BindableFloat | undefined, fallback: number): number {
|
||||
if (b === undefined || b === null) return fallback;
|
||||
if (typeof b === 'number') return b;
|
||||
return b.value ?? fallback;
|
||||
}
|
||||
|
||||
/** Extract the source_id from a BindableFloat (empty string = not bound). */
|
||||
export function bindableSourceId(b: BindableFloat | undefined): string {
|
||||
if (b === undefined || b === null) return '';
|
||||
if (typeof b === 'number') return '';
|
||||
return b.source_id ?? '';
|
||||
}
|
||||
|
||||
// ── Device ────────────────────────────────────────────────────
|
||||
|
||||
export type DeviceType =
|
||||
@@ -59,27 +78,27 @@ export interface OutputTarget {
|
||||
// LED target fields
|
||||
device_id?: string;
|
||||
color_strip_source_id?: string;
|
||||
brightness_value_source_id?: string;
|
||||
fps?: number;
|
||||
brightness?: BindableFloat;
|
||||
fps?: BindableFloat;
|
||||
keepalive_interval?: number;
|
||||
state_check_interval?: number;
|
||||
min_brightness_threshold?: number;
|
||||
min_brightness_threshold?: BindableFloat;
|
||||
adaptive_fps?: boolean;
|
||||
protocol?: string;
|
||||
|
||||
// HA light target fields
|
||||
ha_source_id?: string;
|
||||
ha_light_mappings?: HALightMapping[];
|
||||
update_rate?: number;
|
||||
ha_transition?: number;
|
||||
color_tolerance?: number;
|
||||
update_rate?: BindableFloat;
|
||||
transition?: BindableFloat;
|
||||
color_tolerance?: BindableFloat;
|
||||
}
|
||||
|
||||
export interface HALightMapping {
|
||||
entity_id: string;
|
||||
led_start: number;
|
||||
led_end: number;
|
||||
brightness_scale: number;
|
||||
brightness_scale: BindableFloat;
|
||||
}
|
||||
|
||||
// ── Color Strip Source ────────────────────────────────────────
|
||||
@@ -165,7 +184,7 @@ export interface ColorStripSource {
|
||||
|
||||
// Picture
|
||||
picture_source_id?: string;
|
||||
smoothing?: number;
|
||||
smoothing?: BindableFloat;
|
||||
interpolation_mode?: string;
|
||||
calibration?: Calibration;
|
||||
|
||||
@@ -181,8 +200,8 @@ export interface ColorStripSource {
|
||||
// Effect
|
||||
effect_type?: string;
|
||||
palette?: string;
|
||||
intensity?: number;
|
||||
scale?: number;
|
||||
intensity?: BindableFloat;
|
||||
scale?: BindableFloat;
|
||||
mirror?: boolean;
|
||||
|
||||
// Composite
|
||||
@@ -194,16 +213,16 @@ export interface ColorStripSource {
|
||||
// Audio
|
||||
visualization_mode?: string;
|
||||
audio_source_id?: string;
|
||||
sensitivity?: number;
|
||||
sensitivity?: BindableFloat;
|
||||
color_peak?: number[];
|
||||
|
||||
// Animation
|
||||
animation?: AnimationConfig;
|
||||
speed?: number;
|
||||
speed?: BindableFloat;
|
||||
|
||||
// API Input
|
||||
fallback_color?: number[];
|
||||
timeout?: number;
|
||||
timeout?: BindableFloat;
|
||||
interpolation?: string;
|
||||
|
||||
// Notification
|
||||
@@ -214,6 +233,7 @@ export interface ColorStripSource {
|
||||
app_filter_mode?: string;
|
||||
app_filter_list?: string[];
|
||||
os_listener?: boolean;
|
||||
sound_volume?: BindableFloat;
|
||||
|
||||
// Daylight
|
||||
use_real_time?: boolean;
|
||||
@@ -221,6 +241,7 @@ export interface ColorStripSource {
|
||||
|
||||
// Candlelight
|
||||
num_candles?: number;
|
||||
wind_strength?: BindableFloat;
|
||||
|
||||
// Processed
|
||||
input_source_id?: string;
|
||||
@@ -228,12 +249,11 @@ export interface ColorStripSource {
|
||||
|
||||
// Weather
|
||||
weather_source_id?: string;
|
||||
temperature_influence?: number;
|
||||
temperature_influence?: BindableFloat;
|
||||
|
||||
// Key Colors
|
||||
rectangles?: KeyColorRectangle[];
|
||||
brightness?: number;
|
||||
brightness_value_source_id?: string;
|
||||
brightness?: BindableFloat;
|
||||
}
|
||||
|
||||
// ── Pattern Template ──────────────────────────────────────────
|
||||
@@ -370,7 +390,7 @@ export interface TargetSnapshot {
|
||||
target_id: string;
|
||||
running: boolean;
|
||||
color_strip_source_id: string;
|
||||
brightness_value_source_id: string;
|
||||
brightness?: BindableFloat;
|
||||
fps: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"app.title": "LED Grab",
|
||||
"bindable.none": "None (static value)",
|
||||
"bindable.toggle": "Toggle value source binding",
|
||||
"app.version": "Version:",
|
||||
"app.api_docs": "API Documentation",
|
||||
"app.connection_lost": "Server unreachable",
|
||||
@@ -1331,9 +1333,12 @@
|
||||
"audio_source.parent.hint": "Multichannel source to extract a channel from",
|
||||
"audio_source.channel": "Channel:",
|
||||
"audio_source.channel.hint": "Which audio channel to extract from the multichannel source",
|
||||
"audio_source.channel.mono": "Mono (L+R mix)",
|
||||
"audio_source.channel.mono": "Mono",
|
||||
"audio_source.channel.mono.desc": "L+R mix",
|
||||
"audio_source.channel.left": "Left",
|
||||
"audio_source.channel.left.desc": "Left channel only",
|
||||
"audio_source.channel.right": "Right",
|
||||
"audio_source.channel.right.desc": "Right channel only",
|
||||
"audio_source.description": "Description (optional):",
|
||||
"audio_source.description.placeholder": "Describe this audio source...",
|
||||
"audio_source.description.hint": "Optional notes about this audio source",
|
||||
@@ -1808,6 +1813,10 @@
|
||||
"ha_light.update_rate.hint": "How often to send color updates to HA lights (0.5-5.0 Hz). Lower values are safer for HA performance.",
|
||||
"ha_light.transition": "Transition:",
|
||||
"ha_light.transition.hint": "Smooth fade duration between colors (HA transition parameter).",
|
||||
"ha_light.color_tolerance": "Color Tolerance:",
|
||||
"ha_light.color_tolerance.hint": "Skip sending color updates when the RGB delta is below this threshold. Reduces HA traffic for near-static scenes.",
|
||||
"ha_light.min_brightness_threshold": "Min Brightness Threshold:",
|
||||
"ha_light.min_brightness_threshold.hint": "Effective output brightness below this value turns lights off completely (0 = disabled).",
|
||||
"ha_light.mappings": "Light Mappings:",
|
||||
"ha_light.mappings.hint": "Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.",
|
||||
"ha_light.mappings.add": "Add Mapping",
|
||||
@@ -1830,6 +1839,9 @@
|
||||
"automations.condition.home_assistant.state": "State:",
|
||||
"automations.condition.home_assistant.match_mode": "Match Mode:",
|
||||
"automations.condition.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state",
|
||||
"automations.condition.ha.match_mode.exact.desc": "State must match exactly",
|
||||
"automations.condition.ha.match_mode.contains.desc": "State must contain the text",
|
||||
"automations.condition.ha.match_mode.regex.desc": "State must match the regex pattern",
|
||||
"color_strip.clock": "Sync Clock:",
|
||||
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.",
|
||||
"graph.title": "Graph",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"app.title": "LED Grab",
|
||||
"bindable.none": "Нет (статическое значение)",
|
||||
"bindable.toggle": "Привязка к источнику значений",
|
||||
"ha_light.color_tolerance": "Допуск цвета:",
|
||||
"ha_light.color_tolerance.hint": "Пропускать обновление цвета, если разница RGB ниже этого порога. Снижает нагрузку на HA для статичных сцен.",
|
||||
"ha_light.min_brightness_threshold": "Мин. порог яркости:",
|
||||
"ha_light.min_brightness_threshold.hint": "Эффективная яркость ниже этого значения выключает свет полностью (0 = отключено).",
|
||||
"app.version": "Версия:",
|
||||
"app.api_docs": "Документация API",
|
||||
"app.connection_lost": "Сервер недоступен",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"app.title": "LED Grab",
|
||||
"bindable.none": "无(静态值)",
|
||||
"bindable.toggle": "切换值源绑定",
|
||||
"ha_light.color_tolerance": "色彩容差:",
|
||||
"ha_light.color_tolerance.hint": "当RGB差异低于此阈值时跳过颜色更新,减少HA流量。",
|
||||
"ha_light.min_brightness_threshold": "最低亮度阈值:",
|
||||
"ha_light.min_brightness_threshold.hint": "有效输出亮度低于此值时完全关灯(0=禁用)。",
|
||||
"app.version": "版本:",
|
||||
"app.api_docs": "API 文档",
|
||||
"app.connection_lost": "服务器不可达",
|
||||
|
||||
Reference in New Issue
Block a user