feat: add per-layer LED range and collapsible layers to composite source
Some checks failed
Lint & Test / test (push) Failing after 33s
Some checks failed
Lint & Test / test (push) Failing after 33s
Composite layers now support optional start/end LED range (toggleable) and reverse flag, making composite a superset of mapped source. Layers are collapsible with animated expand/collapse and consistent 0.85rem font sizing. Delete button restyled as ghost icon button. Also includes minor dashboard CSS overflow fixes.
This commit is contained in:
@@ -11,6 +11,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Donation / Open-Source Banner
|
||||||
|
|
||||||
|
- [ ] Add a persistent but dismissible banner or notification in the dashboard UI informing users that the project is open-source and under active development, and that donations are highly appreciated
|
||||||
|
- [ ] Include a link to the donation page (GitHub Sponsors, Ko-fi, or similar — decide on platform)
|
||||||
|
- [ ] Remember dismissal in localStorage so it doesn't reappear every session
|
||||||
|
- [ ] Add i18n keys for the banner text (`en.json`, `ru.json`, `zh.json`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Color Strip Source Improvements
|
# Color Strip Source Improvements
|
||||||
|
|
||||||
## New Source Types
|
## New Source Types
|
||||||
@@ -35,7 +44,6 @@ Need to research HAOS communication options first (WebSocket API, REST API, MQTT
|
|||||||
|
|
||||||
- [x] Add effects: rain, comet, bouncing ball, fireworks, sparkle rain, lava lamp, wave interference
|
- [x] Add effects: rain, comet, bouncing ball, fireworks, sparkle rain, lava lamp, wave interference
|
||||||
- [x] Custom palette support: user-defined [[pos,R,G,B],...] stops via JSON textarea
|
- [x] Custom palette support: user-defined [[pos,R,G,B],...] stops via JSON textarea
|
||||||
- [ ] Custom palette editor: replace raw JSON textarea with a proper visual editor (reuse gradient editor pattern with color pickers + position sliders)
|
|
||||||
|
|
||||||
### `gradient`
|
### `gradient`
|
||||||
|
|
||||||
@@ -63,7 +71,7 @@ Need to research HAOS communication options first (WebSocket API, REST API, MQTT
|
|||||||
|
|
||||||
- [ ] Allow nested composites (with cycle detection)
|
- [ ] Allow nested composites (with cycle detection)
|
||||||
- [ ] More blend modes: overlay, soft light, hard light, difference, exclusion
|
- [ ] More blend modes: overlay, soft light, hard light, difference, exclusion
|
||||||
- [ ] Per-layer LED range masks
|
- [x] Per-layer LED range masks (optional start/end/reverse on each composite layer)
|
||||||
|
|
||||||
### `notification`
|
### `notification`
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class CompositeLayer(BaseModel):
|
|||||||
enabled: bool = Field(default=True, description="Whether this layer is active")
|
enabled: bool = Field(default=True, description="Whether this layer is active")
|
||||||
brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness")
|
brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness")
|
||||||
processing_template_id: Optional[str] = Field(None, description="Optional color strip processing template ID")
|
processing_template_id: Optional[str] = Field(None, description="Optional color strip processing template ID")
|
||||||
|
start: int = Field(default=0, ge=0, description="First LED index for range (0 = full strip)")
|
||||||
|
end: int = Field(default=0, ge=0, description="Last LED index exclusive for range (0 = full strip)")
|
||||||
|
reverse: bool = Field(default=False, description="Reverse layer output within its range")
|
||||||
|
|
||||||
|
|
||||||
class MappedZone(BaseModel):
|
class MappedZone(BaseModel):
|
||||||
|
|||||||
@@ -155,9 +155,13 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
"""Hot-update: rebuild sub-streams if layer config changed."""
|
"""Hot-update: rebuild sub-streams if layer config changed."""
|
||||||
new_layers = list(source.layers)
|
new_layers = list(source.layers)
|
||||||
old_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"), layer.get("enabled"), layer.get("brightness_source_id"))
|
old_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"),
|
||||||
|
layer.get("enabled"), layer.get("brightness_source_id"),
|
||||||
|
layer.get("start", 0), layer.get("end", 0), layer.get("reverse", False))
|
||||||
for layer in self._layers]
|
for layer in self._layers]
|
||||||
new_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"), layer.get("enabled"), layer.get("brightness_source_id"))
|
new_layer_ids = [(layer.get("source_id"), layer.get("blend_mode"), layer.get("opacity"),
|
||||||
|
layer.get("enabled"), layer.get("brightness_source_id"),
|
||||||
|
layer.get("start", 0), layer.get("end", 0), layer.get("reverse", False))
|
||||||
for layer in new_layers]
|
for layer in new_layers]
|
||||||
|
|
||||||
self._layers = new_layers
|
self._layers = new_layers
|
||||||
@@ -187,7 +191,15 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
try:
|
try:
|
||||||
stream = self._css_manager.acquire(src_id, consumer_id)
|
stream = self._css_manager.acquire(src_id, consumer_id)
|
||||||
if hasattr(stream, "configure") and self._led_count > 0:
|
if hasattr(stream, "configure") and self._led_count > 0:
|
||||||
stream.configure(self._led_count)
|
# Configure with zone length if layer has a range, else full strip
|
||||||
|
layer_start = layer.get("start", 0)
|
||||||
|
layer_end = layer.get("end", 0)
|
||||||
|
if layer_start > 0 or layer_end > 0:
|
||||||
|
eff_end = layer_end if layer_end > 0 else self._led_count
|
||||||
|
zone_len = max(0, eff_end - layer_start)
|
||||||
|
stream.configure(zone_len if zone_len > 0 else self._led_count)
|
||||||
|
else:
|
||||||
|
stream.configure(self._led_count)
|
||||||
self._sub_streams[i] = (src_id, consumer_id, stream)
|
self._sub_streams[i] = (src_id, consumer_id, stream)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -416,9 +428,34 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
if _result is not None:
|
if _result is not None:
|
||||||
colors = _result
|
colors = _result
|
||||||
|
|
||||||
# Resize to target LED count if needed
|
# Determine layer range
|
||||||
if len(colors) != target_n:
|
layer_start = layer.get("start", 0)
|
||||||
colors = self._resize_to_target(colors, target_n)
|
layer_end = layer.get("end", 0)
|
||||||
|
has_range = layer_start > 0 or layer_end > 0
|
||||||
|
|
||||||
|
if has_range:
|
||||||
|
# Clamp range to strip bounds
|
||||||
|
eff_start = max(0, min(layer_start, target_n))
|
||||||
|
eff_end = max(eff_start, min(layer_end if layer_end > 0 else target_n, target_n))
|
||||||
|
zone_len = eff_end - eff_start
|
||||||
|
if zone_len <= 0:
|
||||||
|
continue
|
||||||
|
# Resize to zone length
|
||||||
|
if len(colors) != zone_len:
|
||||||
|
src_x = np.linspace(0, 1, len(colors))
|
||||||
|
dst_x = np.linspace(0, 1, zone_len)
|
||||||
|
resized = np.empty((zone_len, 3), dtype=np.uint8)
|
||||||
|
for ch in range(3):
|
||||||
|
np.copyto(resized[:, ch], np.interp(dst_x, src_x, colors[:, ch]), casting="unsafe")
|
||||||
|
colors = resized
|
||||||
|
else:
|
||||||
|
# Full-strip layer: resize to target LED count
|
||||||
|
if len(colors) != target_n:
|
||||||
|
colors = self._resize_to_target(colors, target_n)
|
||||||
|
|
||||||
|
# Reverse if requested
|
||||||
|
if layer.get("reverse", False):
|
||||||
|
colors = colors[::-1].copy()
|
||||||
|
|
||||||
# Apply per-layer brightness from value source
|
# Apply per-layer brightness from value source
|
||||||
if i in self._brightness_streams:
|
if i in self._brightness_streams:
|
||||||
@@ -437,14 +474,20 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
alpha = max(0, min(256, alpha))
|
alpha = max(0, min(256, alpha))
|
||||||
|
|
||||||
if not has_result:
|
if not has_result:
|
||||||
# First layer: copy directly (or blend with black if opacity < 1)
|
result_buf[:] = 0
|
||||||
if alpha >= 256 and blend_mode == _BLEND_NORMAL:
|
|
||||||
result_buf[:] = colors
|
|
||||||
else:
|
|
||||||
result_buf[:] = 0
|
|
||||||
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
|
|
||||||
blend_fn(result_buf, colors, alpha, result_buf)
|
|
||||||
has_result = True
|
has_result = True
|
||||||
|
|
||||||
|
if has_range:
|
||||||
|
# Blend only into the target range — use scratch sub-slices
|
||||||
|
rng = result_buf[eff_start:eff_end]
|
||||||
|
u16a_rng = self._u16_a[:zone_len]
|
||||||
|
u16b_rng = self._u16_b[:zone_len]
|
||||||
|
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
|
||||||
|
# Temporarily swap scratch buffers for the range size
|
||||||
|
orig_u16a, orig_u16b = self._u16_a, self._u16_b
|
||||||
|
self._u16_a, self._u16_b = u16a_rng, u16b_rng
|
||||||
|
blend_fn(rng, colors, alpha, rng)
|
||||||
|
self._u16_a, self._u16_b = orig_u16a, orig_u16b
|
||||||
else:
|
else:
|
||||||
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
|
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
|
||||||
blend_fn(result_buf, colors, alpha, result_buf)
|
blend_fn(result_buf, colors, alpha, result_buf)
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ body {
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
|
overflow-x: hidden;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
|
|||||||
@@ -119,6 +119,11 @@
|
|||||||
color: var(--primary-text-color);
|
color: var(--primary-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-target-info > div {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-target-name {
|
.dashboard-target-name {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -130,6 +135,11 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-target-name-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-card-link {
|
.dashboard-card-link {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1536,6 +1536,78 @@
|
|||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composite-layer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-expand-btn {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-expanded .composite-layer-expand-btn {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-summary {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-summary-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-summary-blend {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-body-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-expanded .composite-layer-body-wrapper {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding-top: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
transition: padding-top 0.2s ease;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-expanded .composite-layer-body {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.composite-layer-row {
|
.composite-layer-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1550,7 +1622,6 @@
|
|||||||
.composite-layer-brightness-label {
|
.composite-layer-brightness-label {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 90px;
|
width: 90px;
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1566,7 +1637,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.composite-layer-opacity-label {
|
.composite-layer-opacity-label {
|
||||||
font-size: 0.8rem;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -1581,11 +1651,66 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.composite-layer-remove-btn {
|
.composite-layer-remove-btn {
|
||||||
font-size: 0.75rem;
|
background: none;
|
||||||
padding: 0;
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
flex: 0 0 26px;
|
flex: 0 0 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-remove-btn:hover {
|
||||||
|
color: var(--danger-color);
|
||||||
|
background: color-mix(in srgb, var(--danger-color) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-range-toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-range-fields {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-range-fields label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-range-fields input[type="number"] {
|
||||||
|
width: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-range-disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composite-layer-reverse-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Composite layer drag-to-reorder ── */
|
/* ── Composite layer drag-to-reorder ── */
|
||||||
|
|||||||
@@ -102,51 +102,130 @@ export function compositeRenderList() {
|
|||||||
`<option value="${tmpl.id}"${layer.processing_template_id === tmpl.id ? ' selected' : ''}>${escapeHtml(tmpl.name)}</option>`
|
`<option value="${tmpl.id}"${layer.processing_template_id === tmpl.id ? ' selected' : ''}>${escapeHtml(tmpl.name)}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
const canRemove = _compositeLayers.length > 1;
|
const canRemove = _compositeLayers.length > 1;
|
||||||
|
const srcName = _compositeAvailableSources.find(s => s.id === layer.source_id)?.name || '—';
|
||||||
|
const blendLabel = t(`color_strip.composite.blend_mode.${layer.blend_mode}`) || layer.blend_mode;
|
||||||
return `
|
return `
|
||||||
<div class="composite-layer-item" data-layer-index="${i}">
|
<div class="composite-layer-item" data-layer-index="${i}">
|
||||||
<div class="composite-layer-row">
|
<div class="composite-layer-header">
|
||||||
<span class="composite-layer-drag-handle" title="${t('filters.drag_to_reorder')}">⠇</span>
|
<span class="composite-layer-drag-handle" title="${t('filters.drag_to_reorder')}">⠇</span>
|
||||||
<select class="composite-layer-source" data-idx="${i}">${srcOptions}</select>
|
<span class="composite-layer-expand-btn">▶</span>
|
||||||
<select class="composite-layer-blend" data-idx="${i}">
|
<span class="composite-layer-summary">
|
||||||
<option value="normal"${layer.blend_mode === 'normal' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.normal')}</option>
|
<span class="composite-layer-summary-name">${escapeHtml(srcName)}</span>
|
||||||
<option value="add"${layer.blend_mode === 'add' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.add')}</option>
|
<span class="composite-layer-summary-blend">${escapeHtml(blendLabel)}</span>
|
||||||
<option value="multiply"${layer.blend_mode === 'multiply' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.multiply')}</option>
|
</span>
|
||||||
<option value="screen"${layer.blend_mode === 'screen' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.screen')}</option>
|
|
||||||
<option value="override"${layer.blend_mode === 'override' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.override')}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<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}">
|
|
||||||
<label class="settings-toggle composite-layer-toggle">
|
<label class="settings-toggle composite-layer-toggle">
|
||||||
<input type="checkbox" class="composite-layer-enabled" data-idx="${i}"${layer.enabled ? ' checked' : ''}>
|
<input type="checkbox" class="composite-layer-enabled" data-idx="${i}"${layer.enabled ? ' checked' : ''}>
|
||||||
<span class="settings-toggle-slider"></span>
|
<span class="settings-toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
${canRemove
|
${canRemove
|
||||||
? `<button type="button" class="btn btn-secondary composite-layer-remove-btn"
|
? `<button type="button" class="composite-layer-remove-btn"
|
||||||
onclick="compositeRemoveLayer(${i})">✕</button>`
|
onclick="compositeRemoveLayer(${i})" title="${t('common.delete')}">✕</button>`
|
||||||
: ''}
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="composite-layer-row">
|
<div class="composite-layer-body-wrapper">
|
||||||
<label class="composite-layer-brightness-label">
|
<div class="composite-layer-body">
|
||||||
<span>${t('color_strip.composite.brightness')}:</span>
|
<div class="composite-layer-row">
|
||||||
</label>
|
<select class="composite-layer-source" data-idx="${i}">${srcOptions}</select>
|
||||||
<select class="composite-layer-brightness" data-idx="${i}">${vsOptions}</select>
|
<select class="composite-layer-blend" data-idx="${i}">
|
||||||
|
<option value="normal"${layer.blend_mode === 'normal' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.normal')}</option>
|
||||||
|
<option value="add"${layer.blend_mode === 'add' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.add')}</option>
|
||||||
|
<option value="multiply"${layer.blend_mode === 'multiply' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.multiply')}</option>
|
||||||
|
<option value="screen"${layer.blend_mode === 'screen' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.screen')}</option>
|
||||||
|
<option value="override"${layer.blend_mode === 'override' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.override')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<div class="composite-layer-row">
|
||||||
|
<label class="composite-layer-brightness-label">
|
||||||
|
<span>${t('color_strip.composite.brightness')}:</span>
|
||||||
|
</label>
|
||||||
|
<select class="composite-layer-brightness" data-idx="${i}">${vsOptions}</select>
|
||||||
|
</div>
|
||||||
|
<div class="composite-layer-row">
|
||||||
|
<label class="composite-layer-brightness-label">
|
||||||
|
<span>${t('color_strip.composite.processing')}:</span>
|
||||||
|
</label>
|
||||||
|
<select class="composite-layer-cspt" data-idx="${i}">${csptOptions}</select>
|
||||||
|
</div>
|
||||||
|
<div class="composite-layer-row composite-layer-range-row">
|
||||||
|
<label class="composite-layer-range-toggle-label">
|
||||||
|
<input type="checkbox" class="composite-layer-range-toggle" data-idx="${i}"${(layer.start > 0 || layer.end > 0) ? ' checked' : ''}>
|
||||||
|
<span>${t('color_strip.composite.range')}</span>
|
||||||
|
</label>
|
||||||
|
<div class="composite-layer-range-fields${(layer.start > 0 || layer.end > 0) ? '' : ' composite-layer-range-disabled'}">
|
||||||
|
<label>${t('color_strip.composite.range_start')}</label>
|
||||||
|
<input type="number" class="composite-layer-start" data-idx="${i}"
|
||||||
|
min="0" value="${layer.start || 0}"${(layer.start > 0 || layer.end > 0) ? '' : ' disabled'}>
|
||||||
|
<label>${t('color_strip.composite.range_end')}</label>
|
||||||
|
<input type="number" class="composite-layer-end" data-idx="${i}"
|
||||||
|
min="0" value="${layer.end || 0}"${(layer.start > 0 || layer.end > 0) ? '' : ' disabled'}>
|
||||||
|
</div>
|
||||||
|
<label class="composite-layer-reverse-label">
|
||||||
|
<input type="checkbox" class="composite-layer-reverse" data-idx="${i}"${layer.reverse ? ' checked' : ''}>
|
||||||
|
<span>${t('color_strip.composite.reverse')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="composite-layer-row">
|
|
||||||
<label class="composite-layer-brightness-label">
|
|
||||||
<span>${t('color_strip.composite.processing')}:</span>
|
|
||||||
</label>
|
|
||||||
<select class="composite-layer-cspt" data-idx="${i}">${csptOptions}</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
// Wire up expand/collapse
|
||||||
|
list.querySelectorAll('.composite-layer-header').forEach(header => {
|
||||||
|
const item = header.closest('.composite-layer-item') as HTMLElement;
|
||||||
|
|
||||||
|
header.addEventListener('click', (e: Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('.settings-toggle, .composite-layer-remove-btn, .composite-layer-drag-handle')) return;
|
||||||
|
item.classList.toggle('composite-layer-expanded');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire up source/blend change to update summary text
|
||||||
|
list.querySelectorAll<HTMLSelectElement>('.composite-layer-source').forEach(sel => {
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
const item = sel.closest('.composite-layer-item') as HTMLElement;
|
||||||
|
const nameEl = item.querySelector('.composite-layer-summary-name') as HTMLElement;
|
||||||
|
const selectedOpt = sel.options[sel.selectedIndex];
|
||||||
|
if (nameEl && selectedOpt) nameEl.textContent = selectedOpt.text;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend').forEach(sel => {
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
const item = sel.closest('.composite-layer-item') as HTMLElement;
|
||||||
|
const blendEl = item.querySelector('.composite-layer-summary-blend') as HTMLElement;
|
||||||
|
const selectedOpt = sel.options[sel.selectedIndex];
|
||||||
|
if (blendEl && selectedOpt) blendEl.textContent = selectedOpt.text;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire up range toggle: enable/disable fields, clear values when unchecked
|
||||||
|
list.querySelectorAll<HTMLInputElement>('.composite-layer-range-toggle').forEach(el => {
|
||||||
|
el.addEventListener('change', () => {
|
||||||
|
const row = el.closest('.composite-layer-range-row')!;
|
||||||
|
const fields = row.querySelector('.composite-layer-range-fields') as HTMLElement;
|
||||||
|
const startInput = fields.querySelector('.composite-layer-start') as HTMLInputElement;
|
||||||
|
const endInput = fields.querySelector('.composite-layer-end') as HTMLInputElement;
|
||||||
|
if (el.checked) {
|
||||||
|
fields.classList.remove('composite-layer-range-disabled');
|
||||||
|
if (startInput) startInput.disabled = false;
|
||||||
|
if (endInput) endInput.disabled = false;
|
||||||
|
} else {
|
||||||
|
fields.classList.add('composite-layer-range-disabled');
|
||||||
|
if (startInput) { startInput.value = '0'; startInput.disabled = true; }
|
||||||
|
if (endInput) { endInput.value = '0'; endInput.disabled = true; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Wire up live opacity display
|
// Wire up live opacity display
|
||||||
list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity').forEach(el => {
|
list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity').forEach(el => {
|
||||||
el.addEventListener('input', () => {
|
el.addEventListener('input', () => {
|
||||||
@@ -205,6 +284,9 @@ export function compositeAddLayer() {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
brightness_source_id: null,
|
brightness_source_id: null,
|
||||||
processing_template_id: null,
|
processing_template_id: null,
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
reverse: false,
|
||||||
});
|
});
|
||||||
compositeRenderList();
|
compositeRenderList();
|
||||||
}
|
}
|
||||||
@@ -225,6 +307,9 @@ function _compositeLayersSyncFromDom() {
|
|||||||
const enableds = list.querySelectorAll<HTMLInputElement>('.composite-layer-enabled');
|
const enableds = list.querySelectorAll<HTMLInputElement>('.composite-layer-enabled');
|
||||||
const briSrcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness');
|
const briSrcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness');
|
||||||
const csptSels = list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt');
|
const csptSels = list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt');
|
||||||
|
const starts = list.querySelectorAll<HTMLInputElement>('.composite-layer-start');
|
||||||
|
const ends = list.querySelectorAll<HTMLInputElement>('.composite-layer-end');
|
||||||
|
const reverses = list.querySelectorAll<HTMLInputElement>('.composite-layer-reverse');
|
||||||
if (srcs.length === _compositeLayers.length) {
|
if (srcs.length === _compositeLayers.length) {
|
||||||
for (let i = 0; i < srcs.length; i++) {
|
for (let i = 0; i < srcs.length; i++) {
|
||||||
_compositeLayers[i].source_id = srcs[i].value;
|
_compositeLayers[i].source_id = srcs[i].value;
|
||||||
@@ -233,6 +318,9 @@ function _compositeLayersSyncFromDom() {
|
|||||||
_compositeLayers[i].enabled = enableds[i].checked;
|
_compositeLayers[i].enabled = enableds[i].checked;
|
||||||
_compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
|
_compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
|
||||||
_compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null;
|
_compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null;
|
||||||
|
_compositeLayers[i].start = starts[i] ? (parseInt(starts[i].value) || 0) : 0;
|
||||||
|
_compositeLayers[i].end = ends[i] ? (parseInt(ends[i].value) || 0) : 0;
|
||||||
|
_compositeLayers[i].reverse = reverses[i] ? reverses[i].checked : false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,6 +481,9 @@ export function compositeGetLayers() {
|
|||||||
};
|
};
|
||||||
if (l.brightness_source_id) layer.brightness_source_id = l.brightness_source_id;
|
if (l.brightness_source_id) layer.brightness_source_id = l.brightness_source_id;
|
||||||
if (l.processing_template_id) layer.processing_template_id = l.processing_template_id;
|
if (l.processing_template_id) layer.processing_template_id = l.processing_template_id;
|
||||||
|
if (l.start) layer.start = l.start;
|
||||||
|
if (l.end) layer.end = l.end;
|
||||||
|
if (l.reverse) layer.reverse = l.reverse;
|
||||||
return layer;
|
return layer;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -407,7 +498,10 @@ export function loadCompositeState(css: any) {
|
|||||||
enabled: l.enabled != null ? l.enabled : true,
|
enabled: l.enabled != null ? l.enabled : true,
|
||||||
brightness_source_id: l.brightness_source_id || null,
|
brightness_source_id: l.brightness_source_id || null,
|
||||||
processing_template_id: l.processing_template_id || null,
|
processing_template_id: l.processing_template_id || null,
|
||||||
|
start: l.start || 0,
|
||||||
|
end: l.end || 0,
|
||||||
|
reverse: l.reverse || false,
|
||||||
}))
|
}))
|
||||||
: [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null, processing_template_id: null }];
|
: [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null, processing_template_id: null, start: 0, end: 0, reverse: false }];
|
||||||
compositeRenderList();
|
compositeRenderList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -598,7 +598,7 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
|
|||||||
<div class="dashboard-target-info">
|
<div class="dashboard-target-info">
|
||||||
<span class="dashboard-target-icon">${icon}</span>
|
<span class="dashboard-target-icon">${icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<div class="dashboard-target-name">${escapeHtml(target.name)}${healthDot}</div>
|
<div class="dashboard-target-name"><span class="dashboard-target-name-text">${escapeHtml(target.name)}</span>${healthDot}</div>
|
||||||
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
|
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1192,6 +1192,10 @@
|
|||||||
"color_strip.composite.error.min_layers": "At least 1 layer is required",
|
"color_strip.composite.error.min_layers": "At least 1 layer is required",
|
||||||
"color_strip.composite.error.no_source": "Each layer must have a source selected",
|
"color_strip.composite.error.no_source": "Each layer must have a source selected",
|
||||||
"color_strip.composite.layers_count": "layers",
|
"color_strip.composite.layers_count": "layers",
|
||||||
|
"color_strip.composite.range": "LED Range",
|
||||||
|
"color_strip.composite.range_start": "Start",
|
||||||
|
"color_strip.composite.range_end": "End",
|
||||||
|
"color_strip.composite.reverse": "Reverse",
|
||||||
"color_strip.mapped.zones": "Zones:",
|
"color_strip.mapped.zones": "Zones:",
|
||||||
"color_strip.mapped.zones.hint": "Each zone maps a color strip source to a specific LED range. Zones are placed side-by-side — gaps between zones stay black.",
|
"color_strip.mapped.zones.hint": "Each zone maps a color strip source to a specific LED range. Zones are placed side-by-side — gaps between zones stay black.",
|
||||||
"color_strip.mapped.add_zone": "+ Add Zone",
|
"color_strip.mapped.add_zone": "+ Add Zone",
|
||||||
|
|||||||
@@ -1157,6 +1157,10 @@
|
|||||||
"color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой",
|
"color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой",
|
||||||
"color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник",
|
"color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник",
|
||||||
"color_strip.composite.layers_count": "слоёв",
|
"color_strip.composite.layers_count": "слоёв",
|
||||||
|
"color_strip.composite.range": "Диапазон LED",
|
||||||
|
"color_strip.composite.range_start": "Начало",
|
||||||
|
"color_strip.composite.range_end": "Конец",
|
||||||
|
"color_strip.composite.reverse": "Реверс",
|
||||||
"color_strip.mapped.zones": "Зоны:",
|
"color_strip.mapped.zones": "Зоны:",
|
||||||
"color_strip.mapped.zones.hint": "Каждая зона привязывает источник цветовой полосы к определённому диапазону LED. Зоны размещаются рядом — промежутки остаются чёрными.",
|
"color_strip.mapped.zones.hint": "Каждая зона привязывает источник цветовой полосы к определённому диапазону LED. Зоны размещаются рядом — промежутки остаются чёрными.",
|
||||||
"color_strip.mapped.add_zone": "+ Добавить зону",
|
"color_strip.mapped.add_zone": "+ Добавить зону",
|
||||||
|
|||||||
@@ -1157,6 +1157,10 @@
|
|||||||
"color_strip.composite.error.min_layers": "至少需要 1 个图层",
|
"color_strip.composite.error.min_layers": "至少需要 1 个图层",
|
||||||
"color_strip.composite.error.no_source": "每个图层必须选择一个源",
|
"color_strip.composite.error.no_source": "每个图层必须选择一个源",
|
||||||
"color_strip.composite.layers_count": "个图层",
|
"color_strip.composite.layers_count": "个图层",
|
||||||
|
"color_strip.composite.range": "LED范围",
|
||||||
|
"color_strip.composite.range_start": "起始",
|
||||||
|
"color_strip.composite.range_end": "结束",
|
||||||
|
"color_strip.composite.reverse": "反转",
|
||||||
"color_strip.mapped.zones": "区域:",
|
"color_strip.mapped.zones": "区域:",
|
||||||
"color_strip.mapped.zones.hint": "每个区域将色带源映射到特定 LED 范围。区域并排放置 — 区域之间的间隙保持黑色。",
|
"color_strip.mapped.zones.hint": "每个区域将色带源映射到特定 LED 范围。区域并排放置 — 区域之间的间隙保持黑色。",
|
||||||
"color_strip.mapped.add_zone": "+ 添加区域",
|
"color_strip.mapped.add_zone": "+ 添加区域",
|
||||||
|
|||||||
@@ -664,7 +664,9 @@ class CompositeColorStripSource(ColorStripSource):
|
|||||||
when led_count == 0.
|
when led_count == 0.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Each layer: {"source_id": str, "blend_mode": str, "opacity": float, "enabled": bool, "brightness_source_id": str|None}
|
# Each layer: {"source_id": str, "blend_mode": str, "opacity": float, "enabled": bool,
|
||||||
|
# "brightness_source_id": str|None, "processing_template_id": str|None,
|
||||||
|
# "start": int (0=full), "end": int (0=full), "reverse": bool}
|
||||||
layers: list = field(default_factory=list)
|
layers: list = field(default_factory=list)
|
||||||
led_count: int = 0 # 0 = use device LED count
|
led_count: int = 0 # 0 = use device LED count
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user