Add EntitySelect/IconSelect UI improvements across modals
- Portal IconSelect popups to document.body with position:fixed to prevent clipping by modal overflow-y:auto - Replace custom scene selectors in automation editor with EntitySelect command-palette pickers (main scene + fallback scene) - Add IconSelect grid for automation deactivation mode (none/revert/fallback) - Add IconSelect grid for automation condition type and match type - Replace mapped zone source dropdowns with EntitySelect pickers - Replace scene target selector with EntityPalette.pick() pattern - Remove effect palette preview bar from CSS editor - Remove sensitivity badge from audio color strip source cards - Clean up unused scene-selector CSS and scene-target-add-row CSS - Add locale keys for all new UI elements across en/ru/zh Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
10
CLAUDE.md
10
CLAUDE.md
@@ -120,6 +120,16 @@ Add hint text to both `en.json` and `ru.json` locale files using a `.hint` suffi
|
||||
|
||||
Do **not** add placeholder options like `-- Select something --`. Populate the `<select>` with real options only and let the first one be selected by default.
|
||||
|
||||
### Enhanced selectors (IconSelect & EntitySelect)
|
||||
|
||||
Plain `<select>` dropdowns should be enhanced with visual selectors depending on the data type:
|
||||
|
||||
- **Predefined options** (source types, effect types, palettes, waveforms, viz modes) → use `IconSelect` from `js/core/icon-select.js`. This replaces the `<select>` with a visual grid of icon+label+description cells. See `_ensureCSSTypeIconSelect()`, `_ensureEffectTypeIconSelect()`, `_ensureInterpolationIconSelect()` in `color-strips.js` for examples.
|
||||
|
||||
- **Entity references** (picture sources, audio sources, devices, templates, clocks) → use `EntitySelect` from `js/core/entity-palette.js`. This replaces the `<select>` with a searchable command-palette-style picker. See `_cssPictureSourceEntitySelect` in `color-strips.js` or `_lineSourceEntitySelect` in `advanced-calibration.js` for examples.
|
||||
|
||||
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
|
||||
|
||||
### Modal dirty check (discard unsaved changes)
|
||||
|
||||
Every editor modal **must** have a dirty check so closing with unsaved changes shows a "Discard unsaved changes?" confirmation. Use the `Modal` base class pattern from `js/core/modal.js`:
|
||||
|
||||
@@ -31,7 +31,7 @@ from wled_controller.core.capture.calibration import (
|
||||
)
|
||||
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage.color_strip_source import ApiInputColorStripSource, NotificationColorStripSource, PictureColorStripSource
|
||||
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, NotificationColorStripSource, PictureColorStripSource
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
@@ -47,7 +47,7 @@ router = APIRouter()
|
||||
def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceResponse:
|
||||
"""Convert a ColorStripSource to a ColorStripSourceResponse."""
|
||||
calibration = None
|
||||
if isinstance(source, PictureColorStripSource) and source.calibration:
|
||||
if isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)) and source.calibration:
|
||||
calibration = CalibrationSchema(**calibration_to_dict(source.calibration))
|
||||
|
||||
# Convert raw stop dicts to ColorStop schema objects for gradient sources
|
||||
@@ -376,7 +376,7 @@ async def test_css_calibration(
|
||||
if body.edges:
|
||||
try:
|
||||
source = store.get_source(source_id)
|
||||
if not isinstance(source, PictureColorStripSource):
|
||||
if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Calibration test is only available for picture color strip sources",
|
||||
@@ -422,12 +422,13 @@ async def start_css_overlay(
|
||||
"""Start screen overlay visualization for a color strip source."""
|
||||
try:
|
||||
source = store.get_source(source_id)
|
||||
if not isinstance(source, PictureColorStripSource):
|
||||
if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
|
||||
raise HTTPException(status_code=400, detail="Overlay is only supported for picture color strip sources")
|
||||
if not source.calibration:
|
||||
raise HTTPException(status_code=400, detail="Color strip source has no calibration configured")
|
||||
|
||||
display_index = _resolve_display_index(source.picture_source_id, picture_source_store)
|
||||
ps_id = getattr(source, "picture_source_id", "") or ""
|
||||
display_index = _resolve_display_index(ps_id, picture_source_store)
|
||||
displays = get_available_displays()
|
||||
if not displays:
|
||||
raise HTTPException(status_code=409, detail="No displays available")
|
||||
|
||||
@@ -45,7 +45,7 @@ from wled_controller.core.capture.screen_capture import (
|
||||
get_available_displays,
|
||||
)
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.color_strip_source import PictureColorStripSource
|
||||
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, PictureColorStripSource
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
|
||||
@@ -820,11 +820,12 @@ async def start_target_overlay(
|
||||
if first_css_id:
|
||||
try:
|
||||
css = color_strip_store.get_source(first_css_id)
|
||||
if isinstance(css, PictureColorStripSource) and css.calibration:
|
||||
if isinstance(css, (PictureColorStripSource, AdvancedPictureColorStripSource)) and css.calibration:
|
||||
calibration = css.calibration
|
||||
# Resolve the display this CSS is capturing
|
||||
from wled_controller.api.routes.color_strip_sources import _resolve_display_index
|
||||
display_index = _resolve_display_index(css.picture_source_id, picture_source_store)
|
||||
ps_id = getattr(css, "picture_source_id", "") or ""
|
||||
display_index = _resolve_display_index(ps_id, picture_source_store)
|
||||
displays = get_available_displays()
|
||||
if displays:
|
||||
display_index = min(display_index, len(displays) - 1)
|
||||
|
||||
@@ -133,14 +133,42 @@ async def update_scene_preset(
|
||||
data: ScenePresetUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||
target_store: OutputTargetStore = Depends(get_output_target_store),
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Update scene preset metadata."""
|
||||
"""Update scene preset metadata and optionally change targets."""
|
||||
# If target_ids changed, update the snapshot: keep state for existing targets,
|
||||
# capture fresh state for newly added targets, drop removed ones.
|
||||
new_targets = None
|
||||
if data.target_ids is not None:
|
||||
try:
|
||||
existing = store.get_preset(preset_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
existing_map = {t.target_id: t for t in existing.targets}
|
||||
new_target_ids = set(data.target_ids)
|
||||
|
||||
# Capture fresh state for newly added targets
|
||||
added_ids = new_target_ids - set(existing_map.keys())
|
||||
fresh = capture_current_snapshot(target_store, manager, added_ids) if added_ids else []
|
||||
fresh_map = {t.target_id: t for t in fresh}
|
||||
|
||||
# Build new target list preserving order from target_ids
|
||||
new_targets = []
|
||||
for tid in data.target_ids:
|
||||
if tid in existing_map:
|
||||
new_targets.append(existing_map[tid])
|
||||
elif tid in fresh_map:
|
||||
new_targets.append(fresh_map[tid])
|
||||
|
||||
try:
|
||||
preset = store.update_preset(
|
||||
preset_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
order=data.order,
|
||||
targets=new_targets,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e))
|
||||
|
||||
@@ -49,7 +49,7 @@ class ColorStripSourceCreate(BaseModel):
|
||||
"""Request to create a color strip source."""
|
||||
|
||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
||||
source_type: Literal["picture", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification"] = Field(default="picture", description="Source type")
|
||||
source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification"] = Field(default="picture", description="Source type")
|
||||
# picture-type fields
|
||||
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
|
||||
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
|
||||
|
||||
@@ -34,9 +34,31 @@ class DeviceUpdate(BaseModel):
|
||||
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
|
||||
|
||||
|
||||
class CalibrationLineSchema(BaseModel):
|
||||
"""One LED line in advanced calibration."""
|
||||
|
||||
picture_source_id: str = Field(description="Picture source (monitor) to sample from")
|
||||
edge: Literal["top", "right", "bottom", "left"] = Field(description="Screen edge to sample")
|
||||
led_count: int = Field(ge=1, description="Number of LEDs in this line")
|
||||
span_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start fraction along edge")
|
||||
span_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End fraction along edge")
|
||||
reverse: bool = Field(default=False, description="Reverse LED direction")
|
||||
border_width: int = Field(default=10, ge=1, le=100, description="Sampling depth in pixels")
|
||||
|
||||
|
||||
class Calibration(BaseModel):
|
||||
"""Calibration configuration for pixel-to-LED mapping."""
|
||||
|
||||
mode: Literal["simple", "advanced"] = Field(
|
||||
default="simple",
|
||||
description="Calibration mode: simple (4-edge) or advanced (multi-source lines)"
|
||||
)
|
||||
# Advanced mode: ordered list of lines
|
||||
lines: Optional[List[CalibrationLineSchema]] = Field(
|
||||
default=None,
|
||||
description="Line list for advanced mode (ignored in simple mode)"
|
||||
)
|
||||
# Simple mode fields
|
||||
layout: Literal["clockwise", "counterclockwise"] = Field(
|
||||
default="clockwise",
|
||||
description="LED strip layout direction"
|
||||
|
||||
@@ -23,11 +23,12 @@ class ScenePresetCreate(BaseModel):
|
||||
|
||||
|
||||
class ScenePresetUpdate(BaseModel):
|
||||
"""Update scene preset metadata (not snapshot data — use recapture for that)."""
|
||||
"""Update scene preset metadata and optionally change which targets are included."""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
order: Optional[int] = None
|
||||
target_ids: Optional[List[str]] = Field(None, description="Update target list: keep state for existing, capture fresh for new, drop removed")
|
||||
|
||||
|
||||
class ScenePresetResponse(BaseModel):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Calibration system for mapping screen pixels to LED positions."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Literal, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Literal, Optional, Set, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -51,6 +51,19 @@ class CalibrationSegment:
|
||||
reverse: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalibrationLine:
|
||||
"""One LED line in advanced calibration — references one picture source edge."""
|
||||
|
||||
picture_source_id: str
|
||||
edge: Literal["top", "right", "bottom", "left"]
|
||||
led_count: int
|
||||
span_start: float = 0.0
|
||||
span_end: float = 1.0
|
||||
reverse: bool = False
|
||||
border_width: int = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalibrationConfig:
|
||||
"""Complete calibration configuration.
|
||||
@@ -59,8 +72,14 @@ class CalibrationConfig:
|
||||
are derived at runtime via the `segments` property.
|
||||
"""
|
||||
|
||||
layout: Literal["clockwise", "counterclockwise"]
|
||||
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"]
|
||||
# Mode: "simple" = 4-edge model (backward compat), "advanced" = generic line list
|
||||
mode: Literal["simple", "advanced"] = "simple"
|
||||
# Advanced mode: ordered list of CalibrationLine objects (ignored in simple mode)
|
||||
lines: List[CalibrationLine] = field(default_factory=list)
|
||||
|
||||
# Simple mode fields (also used as defaults for CalibrationConfig constructor)
|
||||
layout: Literal["clockwise", "counterclockwise"] = "clockwise"
|
||||
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = "bottom_left"
|
||||
offset: int = 0
|
||||
leds_top: int = 0
|
||||
leds_right: int = 0
|
||||
@@ -135,6 +154,20 @@ class CalibrationConfig:
|
||||
Raises:
|
||||
ValueError: If configuration is invalid
|
||||
"""
|
||||
if self.mode == "advanced":
|
||||
if not self.lines:
|
||||
raise ValueError("Advanced calibration must have at least one line")
|
||||
for i, line in enumerate(self.lines):
|
||||
if line.led_count <= 0:
|
||||
raise ValueError(f"Line {i}: LED count must be positive, got {line.led_count}")
|
||||
if not (0.0 <= line.span_start <= 1.0) or not (0.0 <= line.span_end <= 1.0):
|
||||
raise ValueError(f"Line {i}: span must be in [0.0, 1.0]")
|
||||
if line.span_end <= line.span_start:
|
||||
raise ValueError(f"Line {i}: span_end must be greater than span_start")
|
||||
if line.border_width < 1:
|
||||
raise ValueError(f"Line {i}: border_width must be at least 1")
|
||||
return True
|
||||
|
||||
total = self.get_total_leds()
|
||||
if total <= 0:
|
||||
raise ValueError("Calibration must have at least one LED")
|
||||
@@ -154,9 +187,26 @@ class CalibrationConfig:
|
||||
return True
|
||||
|
||||
def get_total_leds(self) -> int:
|
||||
"""Get total number of LEDs across all edges."""
|
||||
"""Get total number of LEDs across all edges/lines."""
|
||||
if self.mode == "advanced":
|
||||
return sum(line.led_count for line in self.lines)
|
||||
return self.leds_top + self.leds_right + self.leds_bottom + self.leds_left
|
||||
|
||||
def get_required_picture_source_ids(self) -> List[str]:
|
||||
"""Get deduplicated list of picture source IDs referenced by lines.
|
||||
|
||||
Returns empty list for simple mode (the stream provides the source).
|
||||
"""
|
||||
if self.mode != "advanced":
|
||||
return []
|
||||
seen: Set[str] = set()
|
||||
result: List[str] = []
|
||||
for line in self.lines:
|
||||
if line.picture_source_id not in seen:
|
||||
seen.add(line.picture_source_id)
|
||||
result.append(line.picture_source_id)
|
||||
return result
|
||||
|
||||
def get_segment_for_edge(self, edge: str) -> CalibrationSegment | None:
|
||||
"""Get segment configuration for a specific edge."""
|
||||
for seg in self.segments:
|
||||
@@ -408,6 +458,216 @@ class PixelMapper:
|
||||
return led_colors
|
||||
|
||||
|
||||
class AdvancedPixelMapper:
|
||||
"""Maps multi-source screen pixels to LED colors for advanced calibration.
|
||||
|
||||
Each CalibrationLine references a picture source and an edge, with its own
|
||||
span and border_width. Frames from multiple sources are passed in as a dict.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
calibration: CalibrationConfig,
|
||||
interpolation_mode: Literal["average", "median", "dominant"] = "average",
|
||||
):
|
||||
self.calibration = calibration
|
||||
self.interpolation_mode = interpolation_mode
|
||||
calibration.validate()
|
||||
|
||||
if interpolation_mode == "average":
|
||||
self._calc_color = calculate_average_color
|
||||
elif interpolation_mode == "median":
|
||||
self._calc_color = calculate_median_color
|
||||
elif interpolation_mode == "dominant":
|
||||
self._calc_color = calculate_dominant_color
|
||||
else:
|
||||
raise ValueError(f"Invalid interpolation mode: {interpolation_mode}")
|
||||
|
||||
total_leds = calibration.get_total_leds()
|
||||
self._total_leds = total_leds
|
||||
self._led_buf = np.zeros((total_leds, 3), dtype=np.uint8)
|
||||
self._use_fast_avg = interpolation_mode == "average"
|
||||
|
||||
# Build segment-like metadata from lines (led_start for each line)
|
||||
offset = calibration.offset % total_leds if total_leds > 0 else 0
|
||||
self._line_indices: List[np.ndarray] = []
|
||||
led_start = 0
|
||||
for line in calibration.lines:
|
||||
indices = np.arange(led_start, led_start + line.led_count)
|
||||
if line.reverse:
|
||||
indices = indices[::-1]
|
||||
if offset > 0:
|
||||
indices = (indices + offset) % total_leds
|
||||
self._line_indices.append(indices)
|
||||
led_start += line.led_count
|
||||
|
||||
# Skip arrays (same logic as PixelMapper)
|
||||
skip_start = calibration.skip_leds_start
|
||||
skip_end = calibration.skip_leds_end
|
||||
self._skip_start = skip_start
|
||||
self._skip_end = skip_end
|
||||
self._active_count = max(0, total_leds - skip_start - skip_end)
|
||||
if 0 < self._active_count < total_leds:
|
||||
self._skip_src = np.linspace(0, total_leds - 1, self._active_count)
|
||||
self._skip_x = np.arange(total_leds, dtype=np.float64)
|
||||
self._skip_float = np.empty((total_leds, 3), dtype=np.float64)
|
||||
self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8)
|
||||
else:
|
||||
self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None
|
||||
|
||||
# Per-line edge cache (keyed by line index to avoid collision)
|
||||
self._edge_cache: Dict[int, tuple] = {}
|
||||
|
||||
logger.info(
|
||||
f"Initialized advanced pixel mapper with {total_leds} LEDs, "
|
||||
f"{len(calibration.lines)} lines, {interpolation_mode} interpolation"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_edge_strip(
|
||||
frame: np.ndarray, edge: str, border_width: int,
|
||||
span_start: float, span_end: float,
|
||||
) -> np.ndarray:
|
||||
"""Extract a border strip from a frame for the given edge and span."""
|
||||
h, w = frame.shape[:2]
|
||||
bw = min(border_width, h // 4, w // 4)
|
||||
|
||||
if edge == "top":
|
||||
strip = frame[:bw, :, :]
|
||||
elif edge == "bottom":
|
||||
strip = frame[-bw:, :, :]
|
||||
elif edge == "right":
|
||||
strip = frame[:, -bw:, :]
|
||||
else: # left
|
||||
strip = frame[:, :bw, :]
|
||||
|
||||
# Apply span
|
||||
if span_start > 0.0 or span_end < 1.0:
|
||||
if edge in ("top", "bottom"):
|
||||
total_w = strip.shape[1]
|
||||
s, e = int(span_start * total_w), int(span_end * total_w)
|
||||
strip = strip[:, s:e, :]
|
||||
else:
|
||||
total_h = strip.shape[0]
|
||||
s, e = int(span_start * total_h), int(span_end * total_h)
|
||||
strip = strip[s:e, :, :]
|
||||
|
||||
return strip
|
||||
|
||||
def _map_edge_average(
|
||||
self, edge_pixels: np.ndarray, edge_name: str, led_count: int,
|
||||
cache_key: int,
|
||||
) -> np.ndarray:
|
||||
"""Vectorized average-color mapping (same algo as PixelMapper)."""
|
||||
if edge_name in ("top", "bottom"):
|
||||
axis = 0
|
||||
edge_len = edge_pixels.shape[1]
|
||||
else:
|
||||
axis = 1
|
||||
edge_len = edge_pixels.shape[0]
|
||||
|
||||
cache = self._edge_cache.get(cache_key)
|
||||
if cache is None or cache[0] != edge_len:
|
||||
step = edge_len / led_count
|
||||
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
|
||||
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
|
||||
np.minimum(boundaries, edge_len, out=boundaries)
|
||||
starts = boundaries[:-1]
|
||||
ends = boundaries[1:]
|
||||
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
|
||||
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
|
||||
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
|
||||
cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf)
|
||||
self._edge_cache[cache_key] = cache
|
||||
|
||||
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache
|
||||
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
|
||||
cumsum_buf[0] = 0
|
||||
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
|
||||
segment_sums = cumsum_buf[ends] - cumsum_buf[starts]
|
||||
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
|
||||
|
||||
def _map_edge_fallback(
|
||||
self, edge_pixels: np.ndarray, edge_name: str, led_count: int,
|
||||
) -> np.ndarray:
|
||||
"""Per-LED color mapping for median/dominant modes."""
|
||||
if edge_name in ("top", "bottom"):
|
||||
edge_len = edge_pixels.shape[1]
|
||||
else:
|
||||
edge_len = edge_pixels.shape[0]
|
||||
|
||||
step = edge_len / led_count
|
||||
result = np.empty((led_count, 3), dtype=np.uint8)
|
||||
for i in range(led_count):
|
||||
start = int(i * step)
|
||||
end = max(start + 1, int((i + 1) * step))
|
||||
end = min(end, edge_len)
|
||||
if edge_name in ("top", "bottom"):
|
||||
segment = edge_pixels[:, start:end, :]
|
||||
else:
|
||||
segment = edge_pixels[start:end, :, :]
|
||||
result[i] = self._calc_color(segment)
|
||||
return result
|
||||
|
||||
def map_lines_to_leds(self, frames: Dict[str, np.ndarray]) -> np.ndarray:
|
||||
"""Map multi-source frames to LED colors using calibration lines.
|
||||
|
||||
Args:
|
||||
frames: Dict mapping picture_source_id to captured frame (H, W, 3) uint8
|
||||
|
||||
Returns:
|
||||
numpy array of shape (total_leds, 3), dtype uint8
|
||||
"""
|
||||
led_array = self._led_buf
|
||||
led_array[:] = 0
|
||||
|
||||
for i, line in enumerate(self.calibration.lines):
|
||||
frame = frames.get(line.picture_source_id)
|
||||
if frame is None:
|
||||
continue
|
||||
|
||||
edge_pixels = self._extract_edge_strip(
|
||||
frame, line.edge, line.border_width,
|
||||
line.span_start, line.span_end,
|
||||
)
|
||||
|
||||
if self._use_fast_avg:
|
||||
colors = self._map_edge_average(
|
||||
edge_pixels, line.edge, line.led_count, cache_key=i,
|
||||
)
|
||||
else:
|
||||
colors = self._map_edge_fallback(
|
||||
edge_pixels, line.edge, line.led_count,
|
||||
)
|
||||
|
||||
led_array[self._line_indices[i]] = colors
|
||||
|
||||
# Phase 3: Physical skip (same as PixelMapper)
|
||||
if self._skip_src is not None:
|
||||
np.copyto(self._skip_float, led_array, casting='unsafe')
|
||||
for ch in range(3):
|
||||
self._skip_resampled[:, ch] = np.round(
|
||||
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
|
||||
).astype(np.uint8)
|
||||
led_array[:] = 0
|
||||
end_idx = self._total_leds - self._skip_end
|
||||
led_array[self._skip_start:end_idx] = self._skip_resampled
|
||||
elif self._active_count <= 0:
|
||||
led_array[:] = 0
|
||||
|
||||
return led_array
|
||||
|
||||
|
||||
def create_pixel_mapper(
|
||||
calibration: CalibrationConfig,
|
||||
interpolation_mode: str = "average",
|
||||
):
|
||||
"""Factory: create the right mapper for the calibration mode."""
|
||||
if calibration.mode == "advanced":
|
||||
return AdvancedPixelMapper(calibration, interpolation_mode)
|
||||
return PixelMapper(calibration, interpolation_mode)
|
||||
|
||||
|
||||
def create_default_calibration(led_count: int) -> CalibrationConfig:
|
||||
"""Create a default calibration for a rectangular screen.
|
||||
|
||||
@@ -464,7 +724,35 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
|
||||
ValueError: If data is invalid
|
||||
"""
|
||||
try:
|
||||
mode = data.get("mode", "simple")
|
||||
|
||||
if mode == "advanced":
|
||||
lines_data = data.get("lines", [])
|
||||
lines = [
|
||||
CalibrationLine(
|
||||
picture_source_id=ld["picture_source_id"],
|
||||
edge=ld["edge"],
|
||||
led_count=ld["led_count"],
|
||||
span_start=ld.get("span_start", 0.0),
|
||||
span_end=ld.get("span_end", 1.0),
|
||||
reverse=ld.get("reverse", False),
|
||||
border_width=ld.get("border_width", 10),
|
||||
)
|
||||
for ld in lines_data
|
||||
]
|
||||
config = CalibrationConfig(
|
||||
mode="advanced",
|
||||
lines=lines,
|
||||
offset=data.get("offset", 0),
|
||||
skip_leds_start=data.get("skip_leds_start", 0),
|
||||
skip_leds_end=data.get("skip_leds_end", 0),
|
||||
)
|
||||
config.validate()
|
||||
return config
|
||||
|
||||
# Simple mode (backward compat — missing "mode" key defaults here)
|
||||
config = CalibrationConfig(
|
||||
mode="simple",
|
||||
layout=data["layout"],
|
||||
start_position=data["start_position"],
|
||||
offset=data.get("offset", 0),
|
||||
@@ -505,7 +793,33 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
|
||||
Returns:
|
||||
Dictionary representation
|
||||
"""
|
||||
if config.mode == "advanced":
|
||||
result: dict = {
|
||||
"mode": "advanced",
|
||||
"lines": [
|
||||
{
|
||||
"picture_source_id": line.picture_source_id,
|
||||
"edge": line.edge,
|
||||
"led_count": line.led_count,
|
||||
"span_start": line.span_start,
|
||||
"span_end": line.span_end,
|
||||
"reverse": line.reverse,
|
||||
"border_width": line.border_width,
|
||||
}
|
||||
for line in config.lines
|
||||
],
|
||||
}
|
||||
if config.offset != 0:
|
||||
result["offset"] = config.offset
|
||||
if config.skip_leds_start > 0:
|
||||
result["skip_leds_start"] = config.skip_leds_start
|
||||
if config.skip_leds_end > 0:
|
||||
result["skip_leds_end"] = config.skip_leds_end
|
||||
return result
|
||||
|
||||
# Simple mode
|
||||
result = {
|
||||
"mode": "simple",
|
||||
"layout": config.layout,
|
||||
"start_position": config.start_position,
|
||||
"offset": config.offset,
|
||||
|
||||
@@ -19,7 +19,7 @@ from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.capture.calibration import CalibrationConfig, PixelMapper
|
||||
from wled_controller.core.capture.calibration import CalibrationConfig, PixelMapper, AdvancedPixelMapper, create_pixel_mapper
|
||||
from wled_controller.core.capture.screen_capture import extract_border_pixels
|
||||
from wled_controller.core.processing.live_stream import LiveStream
|
||||
from wled_controller.utils import get_logger
|
||||
@@ -151,15 +151,22 @@ class PictureColorStripStream(ColorStripStream):
|
||||
restarting the thread (except when the underlying LiveStream changes).
|
||||
"""
|
||||
|
||||
def __init__(self, live_stream: LiveStream, source):
|
||||
def __init__(self, live_stream, source):
|
||||
"""
|
||||
Args:
|
||||
live_stream: Acquired LiveStream (lifecycle managed by ColorStripStreamManager)
|
||||
live_stream: Acquired LiveStream or Dict[str, LiveStream] for advanced mode.
|
||||
Lifecycle managed by ColorStripStreamManager.
|
||||
source: PictureColorStripSource config
|
||||
"""
|
||||
from wled_controller.storage.color_strip_source import PictureColorStripSource
|
||||
|
||||
self._live_stream = live_stream
|
||||
# Support both single LiveStream and dict of streams (advanced mode)
|
||||
if isinstance(live_stream, dict):
|
||||
self._live_streams = live_stream
|
||||
self._live_stream = next(iter(live_stream.values()))
|
||||
else:
|
||||
self._live_streams = {}
|
||||
self._live_stream = live_stream
|
||||
self._fps: int = 30 # internal capture rate (send FPS is on the target)
|
||||
self._smoothing: float = source.smoothing
|
||||
self._brightness: float = source.brightness
|
||||
@@ -167,7 +174,7 @@ class PictureColorStripStream(ColorStripStream):
|
||||
self._gamma: float = source.gamma
|
||||
self._interpolation_mode: str = source.interpolation_mode
|
||||
self._calibration: CalibrationConfig = source.calibration
|
||||
self._pixel_mapper = PixelMapper(
|
||||
self._pixel_mapper = create_pixel_mapper(
|
||||
self._calibration, interpolation_mode=self._interpolation_mode
|
||||
)
|
||||
cal_leds = self._calibration.get_total_leds()
|
||||
@@ -201,6 +208,8 @@ class PictureColorStripStream(ColorStripStream):
|
||||
|
||||
@property
|
||||
def display_index(self) -> Optional[int]:
|
||||
if self._live_streams:
|
||||
return None # multi-source, ambiguous
|
||||
return self._live_stream.display_index
|
||||
|
||||
@property
|
||||
@@ -255,9 +264,9 @@ class PictureColorStripStream(ColorStripStream):
|
||||
|
||||
PixelMapper is rebuilt atomically if calibration or interpolation_mode changed.
|
||||
"""
|
||||
from wled_controller.storage.color_strip_source import PictureColorStripSource
|
||||
from wled_controller.storage.color_strip_source import PictureColorStripSource, AdvancedPictureColorStripSource
|
||||
|
||||
if not isinstance(source, PictureColorStripSource):
|
||||
if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
|
||||
return
|
||||
|
||||
self._smoothing = source.smoothing
|
||||
@@ -277,7 +286,7 @@ class PictureColorStripStream(ColorStripStream):
|
||||
self._calibration = source.calibration
|
||||
cal_leds = source.calibration.get_total_leds()
|
||||
self._led_count = source.led_count if source.led_count > 0 else cal_leds
|
||||
self._pixel_mapper = PixelMapper(
|
||||
self._pixel_mapper = create_pixel_mapper(
|
||||
source.calibration, interpolation_mode=source.interpolation_mode
|
||||
)
|
||||
self._previous_colors = None # Reset smoothing history on calibration change
|
||||
@@ -375,10 +384,21 @@ class PictureColorStripStream(ColorStripStream):
|
||||
t0 = time.perf_counter()
|
||||
|
||||
calibration = self._calibration
|
||||
border_pixels = extract_border_pixels(frame, calibration.border_width)
|
||||
t1 = time.perf_counter()
|
||||
mapper = self._pixel_mapper
|
||||
|
||||
led_colors = self._pixel_mapper.map_border_to_leds(border_pixels)
|
||||
if isinstance(mapper, AdvancedPixelMapper):
|
||||
# Advanced mode: gather frames from all live streams
|
||||
frames_dict = {}
|
||||
for ps_id, ls in self._live_streams.items():
|
||||
f = ls.get_latest_frame()
|
||||
if f is not None:
|
||||
frames_dict[ps_id] = f
|
||||
t1 = time.perf_counter()
|
||||
led_colors = mapper.map_lines_to_leds(frames_dict)
|
||||
else:
|
||||
border_pixels = extract_border_pixels(frame, calibration.border_width)
|
||||
t1 = time.perf_counter()
|
||||
led_colors = mapper.map_border_to_leds(border_pixels)
|
||||
t2 = time.perf_counter()
|
||||
|
||||
# Ensure scratch pool is sized for this frame
|
||||
|
||||
@@ -42,12 +42,14 @@ class _ColorStripEntry:
|
||||
|
||||
stream: ColorStripStream
|
||||
ref_count: int
|
||||
# ID of the picture source whose LiveStream we acquired (for release)
|
||||
picture_source_id: str
|
||||
# IDs of picture sources whose LiveStreams we acquired (for release)
|
||||
picture_source_ids: list = None
|
||||
# Per-consumer target FPS values (target_id → fps)
|
||||
target_fps: Dict[str, int] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.picture_source_ids is None:
|
||||
self.picture_source_ids = []
|
||||
if self.target_fps is None:
|
||||
self.target_fps = {}
|
||||
|
||||
@@ -155,7 +157,7 @@ class ColorStripStreamManager:
|
||||
css_stream.start()
|
||||
key = f"{css_id}:{consumer_id}" if consumer_id else css_id
|
||||
self._streams[key] = _ColorStripEntry(
|
||||
stream=css_stream, ref_count=1, picture_source_id="",
|
||||
stream=css_stream, ref_count=1, picture_source_ids=[],
|
||||
)
|
||||
logger.info(f"Created {source.source_type} stream {key}")
|
||||
return css_stream
|
||||
@@ -167,26 +169,47 @@ class ColorStripStreamManager:
|
||||
logger.info(f"Reusing stream {css_id} (ref_count={entry.ref_count})")
|
||||
return entry.stream
|
||||
|
||||
# Create new picture stream — needs a LiveStream from the capture pipeline
|
||||
from wled_controller.storage.color_strip_source import PictureColorStripSource
|
||||
if not isinstance(source, PictureColorStripSource):
|
||||
# Create new picture stream — needs LiveStream(s) from the capture pipeline
|
||||
from wled_controller.storage.color_strip_source import PictureColorStripSource, AdvancedPictureColorStripSource
|
||||
if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
|
||||
raise ValueError(
|
||||
f"Unsupported sharable source type '{source.source_type}' for {css_id}"
|
||||
)
|
||||
|
||||
if not source.picture_source_id:
|
||||
raise ValueError(
|
||||
f"Color strip source {css_id} has no picture_source_id assigned"
|
||||
)
|
||||
|
||||
# Acquire the underlying live stream (ref-counted)
|
||||
live_stream = self._live_stream_manager.acquire(source.picture_source_id)
|
||||
# Determine required picture sources based on calibration mode
|
||||
required_ps_ids = source.calibration.get_required_picture_source_ids()
|
||||
if not required_ps_ids:
|
||||
# Simple mode: use the CSS source's single picture_source_id
|
||||
ps_id = getattr(source, "picture_source_id", "")
|
||||
if not ps_id:
|
||||
raise ValueError(
|
||||
f"Color strip source {css_id} has no picture_source_id assigned"
|
||||
)
|
||||
required_ps_ids = [ps_id]
|
||||
|
||||
# Acquire all required live streams (with rollback on failure)
|
||||
acquired = {}
|
||||
try:
|
||||
css_stream = PictureColorStripStream(live_stream, source)
|
||||
for ps_id in required_ps_ids:
|
||||
acquired[ps_id] = self._live_stream_manager.acquire(ps_id)
|
||||
except Exception as e:
|
||||
for ps_id in acquired:
|
||||
self._live_stream_manager.release(ps_id)
|
||||
raise ValueError(
|
||||
f"Failed to acquire live streams for source {css_id}: {e}"
|
||||
) from e
|
||||
|
||||
# Create stream (single LiveStream for simple, dict for advanced)
|
||||
try:
|
||||
if len(acquired) == 1 and source.calibration.mode == "simple":
|
||||
live_arg = next(iter(acquired.values()))
|
||||
else:
|
||||
live_arg = acquired
|
||||
css_stream = PictureColorStripStream(live_arg, source)
|
||||
css_stream.start()
|
||||
except Exception as e:
|
||||
self._live_stream_manager.release(source.picture_source_id)
|
||||
for ps_id in acquired:
|
||||
self._live_stream_manager.release(ps_id)
|
||||
raise RuntimeError(
|
||||
f"Failed to start color strip stream for source {css_id}: {e}"
|
||||
) from e
|
||||
@@ -194,7 +217,7 @@ class ColorStripStreamManager:
|
||||
self._streams[css_id] = _ColorStripEntry(
|
||||
stream=css_stream,
|
||||
ref_count=1,
|
||||
picture_source_id=source.picture_source_id,
|
||||
picture_source_ids=list(acquired.keys()),
|
||||
)
|
||||
|
||||
logger.info(f"Created picture color strip stream {css_id}")
|
||||
@@ -229,13 +252,13 @@ class ColorStripStreamManager:
|
||||
source_id = key.split(":")[0] if ":" in key else key
|
||||
self._release_clock(source_id, entry.stream)
|
||||
|
||||
picture_source_id = entry.picture_source_id
|
||||
picture_source_ids = entry.picture_source_ids
|
||||
del self._streams[key]
|
||||
logger.info(f"Removed color strip stream {key}")
|
||||
|
||||
# Release the underlying live stream (not needed for static sources)
|
||||
if picture_source_id:
|
||||
self._live_stream_manager.release(picture_source_id)
|
||||
# Release all underlying live streams
|
||||
for ps_id in picture_source_ids:
|
||||
self._live_stream_manager.release(ps_id)
|
||||
|
||||
def update_source(self, css_id: str, new_source) -> None:
|
||||
"""Hot-update processing params on all running streams for a source.
|
||||
@@ -280,15 +303,19 @@ class ColorStripStreamManager:
|
||||
source_id = key.split(":")[0] if ":" in key else key
|
||||
self._release_clock(source_id, entry.stream)
|
||||
|
||||
# Track picture_source_id change for future reference counting
|
||||
from wled_controller.storage.color_strip_source import PictureColorStripSource
|
||||
if isinstance(new_source, PictureColorStripSource):
|
||||
# Track picture source changes for future reference counting
|
||||
from wled_controller.storage.color_strip_source import PictureColorStripSource, AdvancedPictureColorStripSource
|
||||
if isinstance(new_source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
|
||||
new_ps_ids = new_source.calibration.get_required_picture_source_ids()
|
||||
if not new_ps_ids:
|
||||
ps_id = getattr(new_source, "picture_source_id", "")
|
||||
new_ps_ids = [ps_id] if ps_id else []
|
||||
for key in matching_keys:
|
||||
entry = self._streams[key]
|
||||
if new_source.picture_source_id != entry.picture_source_id:
|
||||
if set(new_ps_ids) != set(entry.picture_source_ids):
|
||||
logger.info(
|
||||
f"CSS {css_id}: picture_source_id changed — "
|
||||
f"restart target to use new source"
|
||||
f"CSS {css_id}: picture source set changed — "
|
||||
f"restart target to use new sources"
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
@@ -174,103 +174,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Scene selector (searchable combobox) */
|
||||
.scene-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.scene-selector-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scene-selector-input {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.scene-selector-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scene-selector-clear.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scene-selector-clear:hover {
|
||||
color: var(--danger-color, #dc3545);
|
||||
}
|
||||
|
||||
.scene-selector-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
background: var(--bg-color);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.scene-selector-dropdown.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scene-selector-item {
|
||||
padding: 6px 10px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.scene-selector-item:hover {
|
||||
background: rgba(33, 150, 243, 0.15);
|
||||
}
|
||||
|
||||
.scene-selector-item.selected {
|
||||
background: rgba(33, 150, 243, 0.2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scene-selector-item .scene-color-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scene-selector-empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Webhook URL row */
|
||||
.webhook-url-row {
|
||||
display: flex;
|
||||
|
||||
@@ -450,3 +450,4 @@
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -443,13 +443,6 @@ textarea:focus-visible {
|
||||
.endpoint-label { display: block; font-weight: 600; margin-bottom: 2px; opacity: 0.7; font-size: 0.8em; }
|
||||
|
||||
/* Scene target selector */
|
||||
.scene-target-add-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.scene-target-add-row select { flex: 1; }
|
||||
.scene-target-add-row .btn { padding: 4px 10px; min-width: 0; flex: 0 0 auto; font-size: 0.85rem; }
|
||||
.scene-target-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -510,7 +503,8 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.icon-select-popup {
|
||||
position: relative;
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
|
||||
@@ -760,14 +760,6 @@
|
||||
|
||||
/* ── Gradient editor ────────────────────────────────────────────── */
|
||||
|
||||
.effect-palette-preview {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
margin-top: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.gradient-editor {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -108,7 +108,7 @@ import {
|
||||
// Layer 5: color-strip sources
|
||||
import {
|
||||
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
||||
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange, updateEffectPreview,
|
||||
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange,
|
||||
colorCycleAddColor, colorCycleRemoveColor,
|
||||
compositeAddLayer, compositeRemoveLayer,
|
||||
mappedAddZone, mappedRemoveZone,
|
||||
@@ -142,6 +142,11 @@ import {
|
||||
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
|
||||
showCSSCalibration, toggleCalibrationOverlay,
|
||||
} from './features/calibration.js';
|
||||
import {
|
||||
showAdvancedCalibration, closeAdvancedCalibration, saveAdvancedCalibration,
|
||||
addCalibrationLine, removeCalibrationLine, selectCalibrationLine, moveCalibrationLine,
|
||||
updateCalibrationLine, resetCalibrationView,
|
||||
} from './features/advanced-calibration.js';
|
||||
|
||||
// Layer 6: tabs, navigation, command palette, settings
|
||||
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.js';
|
||||
@@ -371,7 +376,6 @@ Object.assign(window, {
|
||||
onEffectTypeChange,
|
||||
onCSSClockChange,
|
||||
onAnimationTypeChange,
|
||||
updateEffectPreview,
|
||||
colorCycleAddColor,
|
||||
colorCycleRemoveColor,
|
||||
compositeAddLayer,
|
||||
@@ -421,6 +425,17 @@ Object.assign(window, {
|
||||
showCSSCalibration,
|
||||
toggleCalibrationOverlay,
|
||||
|
||||
// advanced calibration
|
||||
showAdvancedCalibration,
|
||||
closeAdvancedCalibration,
|
||||
saveAdvancedCalibration,
|
||||
addCalibrationLine,
|
||||
removeCalibrationLine,
|
||||
selectCalibrationLine,
|
||||
moveCalibrationLine,
|
||||
updateCalibrationLine,
|
||||
resetCalibrationView,
|
||||
|
||||
// tabs / navigation / command palette
|
||||
switchTab,
|
||||
startAutoRefresh,
|
||||
|
||||
@@ -64,7 +64,7 @@ export class IconSelect {
|
||||
// Hide the native select
|
||||
this._select.style.display = 'none';
|
||||
|
||||
// Build trigger button
|
||||
// Build trigger button (inserted next to select)
|
||||
this._trigger = document.createElement('button');
|
||||
this._trigger.type = 'button';
|
||||
this._trigger.className = 'icon-select-trigger';
|
||||
@@ -74,13 +74,13 @@ export class IconSelect {
|
||||
});
|
||||
this._select.parentNode.insertBefore(this._trigger, this._select.nextSibling);
|
||||
|
||||
// Build popup
|
||||
// Build popup (portaled to body to avoid overflow clipping)
|
||||
this._popup = document.createElement('div');
|
||||
this._popup.className = POPUP_CLASS;
|
||||
this._popup.addEventListener('click', (e) => e.stopPropagation());
|
||||
this._popup.addEventListener('transitionend', this._onTransitionEnd);
|
||||
this._popup.innerHTML = this._buildGrid();
|
||||
this._select.parentNode.insertBefore(this._popup, this._trigger.nextSibling);
|
||||
document.body.appendChild(this._popup);
|
||||
|
||||
// Bind item clicks
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
@@ -125,10 +125,28 @@ export class IconSelect {
|
||||
});
|
||||
}
|
||||
|
||||
_positionPopup() {
|
||||
const rect = this._trigger.getBoundingClientRect();
|
||||
this._popup.style.left = rect.left + 'px';
|
||||
this._popup.style.width = Math.max(rect.width, 200) + 'px';
|
||||
|
||||
// Check if there's enough space below, otherwise open upward
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
if (spaceBelow < 250 && spaceAbove > spaceBelow) {
|
||||
this._popup.style.top = '';
|
||||
this._popup.style.bottom = (window.innerHeight - rect.top) + 'px';
|
||||
} else {
|
||||
this._popup.style.top = rect.bottom + 'px';
|
||||
this._popup.style.bottom = '';
|
||||
}
|
||||
}
|
||||
|
||||
_toggle() {
|
||||
const wasOpen = this._popup.classList.contains('open');
|
||||
closeAllIconSelects();
|
||||
if (!wasOpen) {
|
||||
this._positionPopup();
|
||||
this._popup.classList.add('open');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const _svg = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb), key_colors: _svg(P.palette) };
|
||||
const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image) };
|
||||
const _colorStripTypeIcons = {
|
||||
picture_advanced: _svg(P.monitor),
|
||||
static: _svg(P.palette), color_cycle: _svg(P.refreshCw), gradient: _svg(P.rainbow),
|
||||
effect: _svg(P.zap), composite: _svg(P.link),
|
||||
mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICO
|
||||
import * as P from '../core/icon-paths.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { attachProcessPicker } from '../core/process-picker.js';
|
||||
import { csScenes, createSceneCard } from './scene-presets.js';
|
||||
|
||||
@@ -227,6 +228,7 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
condList.innerHTML = '';
|
||||
|
||||
_ensureConditionLogicIconSelect();
|
||||
_ensureDeactivationModeIconSelect();
|
||||
|
||||
// Fetch scenes for selector
|
||||
try {
|
||||
@@ -235,6 +237,7 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
|
||||
// Reset deactivation mode
|
||||
document.getElementById('automation-deactivation-mode').value = 'none';
|
||||
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none');
|
||||
document.getElementById('automation-fallback-scene-group').style.display = 'none';
|
||||
|
||||
if (automationId) {
|
||||
@@ -255,12 +258,14 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
}
|
||||
|
||||
// Scene selector
|
||||
_initSceneSelector('automation-scene', automation.scene_preset_id);
|
||||
_initSceneSelector('automation-scene-id', automation.scene_preset_id);
|
||||
|
||||
// Deactivation mode
|
||||
document.getElementById('automation-deactivation-mode').value = automation.deactivation_mode || 'none';
|
||||
const deactMode = automation.deactivation_mode || 'none';
|
||||
document.getElementById('automation-deactivation-mode').value = deactMode;
|
||||
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode);
|
||||
_onDeactivationModeChange();
|
||||
_initSceneSelector('automation-fallback-scene', automation.deactivation_scene_preset_id);
|
||||
_initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id);
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
return;
|
||||
@@ -281,11 +286,13 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
addAutomationConditionRow(clonedCond);
|
||||
}
|
||||
|
||||
_initSceneSelector('automation-scene', cloneData.scene_preset_id);
|
||||
_initSceneSelector('automation-scene-id', cloneData.scene_preset_id);
|
||||
|
||||
document.getElementById('automation-deactivation-mode').value = cloneData.deactivation_mode || 'none';
|
||||
const cloneDeactMode = cloneData.deactivation_mode || 'none';
|
||||
document.getElementById('automation-deactivation-mode').value = cloneDeactMode;
|
||||
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode);
|
||||
_onDeactivationModeChange();
|
||||
_initSceneSelector('automation-fallback-scene', cloneData.deactivation_scene_preset_id);
|
||||
_initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id);
|
||||
} else {
|
||||
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
|
||||
idInput.value = '';
|
||||
@@ -293,8 +300,8 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
enabledInput.checked = true;
|
||||
logicSelect.value = 'or';
|
||||
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue('or');
|
||||
_initSceneSelector('automation-scene', null);
|
||||
_initSceneSelector('automation-fallback-scene', null);
|
||||
_initSceneSelector('automation-scene-id', null);
|
||||
_initSceneSelector('automation-fallback-scene-id', null);
|
||||
}
|
||||
|
||||
// Wire up deactivation mode change
|
||||
@@ -319,102 +326,60 @@ export async function closeAutomationEditorModal() {
|
||||
await automationModal.close();
|
||||
}
|
||||
|
||||
// ===== Scene selector logic =====
|
||||
// ===== Scene selector (EntitySelect) =====
|
||||
|
||||
function _initSceneSelector(prefix, selectedId) {
|
||||
const hiddenInput = document.getElementById(`${prefix}-id`);
|
||||
const searchInput = document.getElementById(`${prefix}-search`);
|
||||
const clearBtn = document.getElementById(`${prefix}-clear`);
|
||||
const dropdown = document.getElementById(`${prefix}-dropdown`);
|
||||
let _sceneEntitySelect = null;
|
||||
let _fallbackSceneEntitySelect = null;
|
||||
|
||||
hiddenInput.value = selectedId || '';
|
||||
function _getSceneItems() {
|
||||
return (scenePresetsCache.data || []).map(s => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
icon: `<span class="scene-color-dot" style="background:${s.color || '#4fc3f7'}"></span>`,
|
||||
}));
|
||||
}
|
||||
|
||||
// Set initial display text
|
||||
if (selectedId) {
|
||||
const scene = scenePresetsCache.data.find(s => s.id === selectedId);
|
||||
searchInput.value = scene ? scene.name : '';
|
||||
clearBtn.classList.toggle('visible', true);
|
||||
} else {
|
||||
searchInput.value = '';
|
||||
clearBtn.classList.toggle('visible', false);
|
||||
}
|
||||
function _initSceneSelector(selectId, selectedId) {
|
||||
const sel = document.getElementById(selectId);
|
||||
// Populate <select> with scene options
|
||||
sel.innerHTML = (scenePresetsCache.data || []).map(s =>
|
||||
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
sel.value = selectedId || '';
|
||||
|
||||
// Render dropdown items
|
||||
function renderDropdown(filter) {
|
||||
const query = (filter || '').toLowerCase();
|
||||
const filtered = query ? scenePresetsCache.data.filter(s => s.name.toLowerCase().includes(query)) : scenePresetsCache.data;
|
||||
// Determine which EntitySelect slot to use
|
||||
const isMain = selectId === 'automation-scene-id';
|
||||
const existing = isMain ? _sceneEntitySelect : _fallbackSceneEntitySelect;
|
||||
if (existing) existing.destroy();
|
||||
|
||||
if (filtered.length === 0) {
|
||||
dropdown.innerHTML = `<div class="scene-selector-empty">${t('automations.scene.none_available')}</div>`;
|
||||
} else {
|
||||
dropdown.innerHTML = filtered.map(s => {
|
||||
const selected = s.id === hiddenInput.value ? ' selected' : '';
|
||||
return `<div class="scene-selector-item${selected}" data-scene-id="${s.id}"><span class="scene-color-dot" style="background:${escapeHtml(s.color || '#4fc3f7')}"></span>${escapeHtml(s.name)}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
const es = new EntitySelect({
|
||||
target: sel,
|
||||
getItems: _getSceneItems,
|
||||
placeholder: t('automations.scene.search_placeholder'),
|
||||
allowNone: true,
|
||||
noneLabel: t('automations.scene.none_selected'),
|
||||
});
|
||||
if (isMain) _sceneEntitySelect = es; else _fallbackSceneEntitySelect = es;
|
||||
}
|
||||
|
||||
// Attach click handlers
|
||||
dropdown.querySelectorAll('.scene-selector-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const id = item.dataset.sceneId;
|
||||
const scene = scenePresetsCache.data.find(s => s.id === id);
|
||||
hiddenInput.value = id;
|
||||
searchInput.value = scene ? scene.name : '';
|
||||
clearBtn.classList.toggle('visible', true);
|
||||
dropdown.classList.remove('open');
|
||||
});
|
||||
});
|
||||
}
|
||||
// ===== Deactivation mode IconSelect =====
|
||||
|
||||
// Show dropdown on focus/click
|
||||
searchInput.onfocus = () => {
|
||||
renderDropdown(searchInput.value);
|
||||
dropdown.classList.add('open');
|
||||
};
|
||||
const DEACT_MODE_KEYS = ['none', 'revert', 'fallback_scene'];
|
||||
const DEACT_MODE_ICONS = {
|
||||
none: P.pause, revert: P.undo2, fallback_scene: P.sparkles,
|
||||
};
|
||||
let _deactivationModeIconSelect = null;
|
||||
|
||||
searchInput.oninput = () => {
|
||||
renderDropdown(searchInput.value);
|
||||
dropdown.classList.add('open');
|
||||
// If text doesn't match any scene, clear the hidden input
|
||||
const exactMatch = scenePresetsCache.data.find(s => s.name.toLowerCase() === searchInput.value.toLowerCase());
|
||||
if (!exactMatch) {
|
||||
hiddenInput.value = '';
|
||||
clearBtn.classList.toggle('visible', !!searchInput.value);
|
||||
}
|
||||
};
|
||||
|
||||
searchInput.onkeydown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
// Select first visible item
|
||||
const first = dropdown.querySelector('.scene-selector-item');
|
||||
if (first) first.click();
|
||||
} else if (e.key === 'Escape') {
|
||||
dropdown.classList.remove('open');
|
||||
searchInput.blur();
|
||||
}
|
||||
};
|
||||
|
||||
// Clear button
|
||||
clearBtn.onclick = () => {
|
||||
hiddenInput.value = '';
|
||||
searchInput.value = '';
|
||||
clearBtn.classList.remove('visible');
|
||||
dropdown.classList.remove('open');
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
const selectorEl = searchInput.closest('.scene-selector');
|
||||
// Remove old listener if any (re-init)
|
||||
if (selectorEl._outsideClickHandler) {
|
||||
document.removeEventListener('click', selectorEl._outsideClickHandler);
|
||||
}
|
||||
selectorEl._outsideClickHandler = (e) => {
|
||||
if (!selectorEl.contains(e.target)) {
|
||||
dropdown.classList.remove('open');
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', selectorEl._outsideClickHandler);
|
||||
function _ensureDeactivationModeIconSelect() {
|
||||
const sel = document.getElementById('automation-deactivation-mode');
|
||||
if (!sel || _deactivationModeIconSelect) return;
|
||||
const items = DEACT_MODE_KEYS.map(k => ({
|
||||
value: k,
|
||||
icon: _icon(DEACT_MODE_ICONS[k]),
|
||||
label: t(`automations.deactivation_mode.${k}`),
|
||||
desc: t(`automations.deactivation_mode.${k}.desc`),
|
||||
}));
|
||||
_deactivationModeIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
||||
}
|
||||
|
||||
// ===== Condition editor =====
|
||||
@@ -423,6 +388,36 @@ export function addAutomationCondition() {
|
||||
addAutomationConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
|
||||
}
|
||||
|
||||
const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook'];
|
||||
const CONDITION_TYPE_ICONS = {
|
||||
always: P.refreshCw, startup: P.power, application: P.smartphone,
|
||||
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
|
||||
mqtt: P.radio, webhook: P.globe,
|
||||
};
|
||||
|
||||
const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen'];
|
||||
const MATCH_TYPE_ICONS = {
|
||||
running: P.play, topmost: P.monitor, topmost_fullscreen: P.tv, fullscreen: P.square,
|
||||
};
|
||||
|
||||
function _buildMatchTypeItems() {
|
||||
return MATCH_TYPE_KEYS.map(k => ({
|
||||
value: k,
|
||||
icon: _icon(MATCH_TYPE_ICONS[k]),
|
||||
label: t(`automations.condition.application.match_type.${k}`),
|
||||
desc: t(`automations.condition.application.match_type.${k}.desc`),
|
||||
}));
|
||||
}
|
||||
|
||||
function _buildConditionTypeItems() {
|
||||
return CONDITION_TYPE_KEYS.map(k => ({
|
||||
value: k,
|
||||
icon: _icon(CONDITION_TYPE_ICONS[k]),
|
||||
label: t(`automations.condition.${k}`),
|
||||
desc: t(`automations.condition.${k}.desc`),
|
||||
}));
|
||||
}
|
||||
|
||||
function addAutomationConditionRow(condition) {
|
||||
const list = document.getElementById('automation-conditions-list');
|
||||
const row = document.createElement('div');
|
||||
@@ -432,14 +427,7 @@ function addAutomationConditionRow(condition) {
|
||||
row.innerHTML = `
|
||||
<div class="condition-header">
|
||||
<select class="condition-type-select">
|
||||
<option value="always" ${condType === 'always' ? 'selected' : ''}>${t('automations.condition.always')}</option>
|
||||
<option value="startup" ${condType === 'startup' ? 'selected' : ''}>${t('automations.condition.startup')}</option>
|
||||
<option value="application" ${condType === 'application' ? 'selected' : ''}>${t('automations.condition.application')}</option>
|
||||
<option value="time_of_day" ${condType === 'time_of_day' ? 'selected' : ''}>${t('automations.condition.time_of_day')}</option>
|
||||
<option value="system_idle" ${condType === 'system_idle' ? 'selected' : ''}>${t('automations.condition.system_idle')}</option>
|
||||
<option value="display_state" ${condType === 'display_state' ? 'selected' : ''}>${t('automations.condition.display_state')}</option>
|
||||
<option value="mqtt" ${condType === 'mqtt' ? 'selected' : ''}>${t('automations.condition.mqtt')}</option>
|
||||
<option value="webhook" ${condType === 'webhook' ? 'selected' : ''}>${t('automations.condition.webhook')}</option>
|
||||
${CONDITION_TYPE_KEYS.map(k => `<option value="${k}" ${condType === k ? 'selected' : ''}>${t('automations.condition.' + k)}</option>`).join('')}
|
||||
</select>
|
||||
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove()" title="Remove">✕</button>
|
||||
</div>
|
||||
@@ -449,6 +437,13 @@ function addAutomationConditionRow(condition) {
|
||||
const typeSelect = row.querySelector('.condition-type-select');
|
||||
const container = row.querySelector('.condition-fields-container');
|
||||
|
||||
// Attach IconSelect to the condition type dropdown
|
||||
const condIconSelect = new IconSelect({
|
||||
target: typeSelect,
|
||||
items: _buildConditionTypeItems(),
|
||||
columns: 4,
|
||||
});
|
||||
|
||||
function renderFields(type, data) {
|
||||
if (type === 'always') {
|
||||
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.always.hint')}</small>`;
|
||||
@@ -585,6 +580,16 @@ function addAutomationConditionRow(condition) {
|
||||
`;
|
||||
const textarea = container.querySelector('.condition-apps');
|
||||
attachProcessPicker(container, textarea);
|
||||
|
||||
// Attach IconSelect to match type
|
||||
const matchSel = container.querySelector('.condition-match-type');
|
||||
if (matchSel) {
|
||||
new IconSelect({
|
||||
target: matchSel,
|
||||
items: _buildMatchTypeItems(),
|
||||
columns: 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderFields(condType, condition);
|
||||
|
||||
@@ -81,7 +81,7 @@ let _cssClockEntitySelect = null;
|
||||
/* ── Icon-grid type selector ──────────────────────────────────── */
|
||||
|
||||
const CSS_TYPE_KEYS = [
|
||||
'picture', 'static', 'gradient', 'color_cycle',
|
||||
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
|
||||
'effect', 'composite', 'mapped', 'audio',
|
||||
'api_input', 'notification',
|
||||
];
|
||||
@@ -118,7 +118,11 @@ export function onCSSTypeChange() {
|
||||
const type = document.getElementById('css-editor-type').value;
|
||||
// Sync icon-select trigger display
|
||||
if (_cssTypeIconSelect) _cssTypeIconSelect.setValue(type);
|
||||
document.getElementById('css-editor-picture-section').style.display = type === 'picture' ? '' : 'none';
|
||||
const isPictureType = type === 'picture' || type === 'picture_advanced';
|
||||
document.getElementById('css-editor-picture-section').style.display = isPictureType ? '' : 'none';
|
||||
// Hide picture source dropdown for advanced (sources are per-line in calibration)
|
||||
const psGroup = document.getElementById('css-editor-picture-source-group');
|
||||
if (psGroup) psGroup.style.display = (type === 'picture') ? '' : 'none';
|
||||
document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none';
|
||||
document.getElementById('css-editor-color-cycle-section').style.display = type === 'color_cycle' ? '' : 'none';
|
||||
document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none';
|
||||
@@ -129,6 +133,7 @@ export function onCSSTypeChange() {
|
||||
document.getElementById('css-editor-api-input-section').style.display = type === 'api_input' ? '' : 'none';
|
||||
document.getElementById('css-editor-notification-section').style.display = type === 'notification' ? '' : 'none';
|
||||
|
||||
if (isPictureType) _ensureInterpolationIconSelect();
|
||||
if (type === 'effect') {
|
||||
_ensureEffectTypeIconSelect();
|
||||
_ensureEffectPaletteIconSelect();
|
||||
@@ -171,8 +176,8 @@ export function onCSSTypeChange() {
|
||||
}
|
||||
_syncAnimationSpeedState();
|
||||
|
||||
// LED count — only shown for picture, api_input, notification
|
||||
const hasLedCount = ['picture', 'api_input'];
|
||||
// LED count — only shown for picture, picture_advanced, api_input
|
||||
const hasLedCount = ['picture', 'picture_advanced', 'api_input'];
|
||||
document.getElementById('css-editor-led-count-group').style.display =
|
||||
hasLedCount.includes(type) ? '' : 'none';
|
||||
|
||||
@@ -190,6 +195,8 @@ export function onCSSTypeChange() {
|
||||
} else if (type === 'gradient') {
|
||||
requestAnimationFrame(() => gradientRenderAll());
|
||||
}
|
||||
|
||||
_autoGenerateCSSName();
|
||||
}
|
||||
|
||||
function _populateClockDropdown(selectedId) {
|
||||
@@ -275,6 +282,7 @@ function _gradientPresetStripHTML(stops, w = 80, h = 16) {
|
||||
|
||||
/* ── Effect / audio palette IconSelect instances ─────────────── */
|
||||
|
||||
let _interpolationIconSelect = null;
|
||||
let _effectTypeIconSelect = null;
|
||||
let _effectPaletteIconSelect = null;
|
||||
let _audioPaletteIconSelect = null;
|
||||
@@ -284,6 +292,18 @@ let _notificationEffectIconSelect = null;
|
||||
|
||||
const _icon = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||
|
||||
function _ensureInterpolationIconSelect() {
|
||||
const sel = document.getElementById('css-editor-interpolation');
|
||||
if (!sel) return;
|
||||
const items = [
|
||||
{ value: 'average', icon: _icon(P.slidersHorizontal), label: t('color_strip.interpolation.average'), desc: t('color_strip.interpolation.average.desc') },
|
||||
{ value: 'median', icon: _icon(P.activity), label: t('color_strip.interpolation.median'), desc: t('color_strip.interpolation.median.desc') },
|
||||
{ value: 'dominant', icon: _icon(P.target), label: t('color_strip.interpolation.dominant'), desc: t('color_strip.interpolation.dominant.desc') },
|
||||
];
|
||||
if (_interpolationIconSelect) { _interpolationIconSelect.updateItems(items); return; }
|
||||
_interpolationIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
||||
}
|
||||
|
||||
function _ensureEffectTypeIconSelect() {
|
||||
const sel = document.getElementById('css-editor-effect-type');
|
||||
if (!sel) return;
|
||||
@@ -370,25 +390,6 @@ const _PALETTE_COLORS = {
|
||||
};
|
||||
|
||||
// Default palette per effect type
|
||||
const _EFFECT_DEFAULT_PALETTE = {
|
||||
fire: 'fire', meteor: 'fire', plasma: 'rainbow', noise: 'rainbow', aurora: 'aurora',
|
||||
};
|
||||
|
||||
export function updateEffectPreview() {
|
||||
const el = document.getElementById('css-editor-effect-preview');
|
||||
if (!el) return;
|
||||
const et = document.getElementById('css-editor-effect-type').value;
|
||||
if (et === 'meteor') {
|
||||
const color = document.getElementById('css-editor-effect-color').value;
|
||||
el.style.background = color;
|
||||
} else {
|
||||
const palette = document.getElementById('css-editor-effect-palette').value || _EFFECT_DEFAULT_PALETTE[et] || 'fire';
|
||||
const pts = _PALETTE_COLORS[palette] || _PALETTE_COLORS.fire;
|
||||
const stops = pts.map(([pos, rgb]) => `rgb(${rgb}) ${(pos * 100).toFixed(0)}%`).join(', ');
|
||||
el.style.background = `linear-gradient(to right, ${stops})`;
|
||||
}
|
||||
}
|
||||
|
||||
export function onEffectTypeChange() {
|
||||
const et = document.getElementById('css-editor-effect-type').value;
|
||||
// palette: all except meteor
|
||||
@@ -410,8 +411,7 @@ export function onEffectTypeChange() {
|
||||
descEl.textContent = desc;
|
||||
descEl.style.display = desc ? '' : 'none';
|
||||
}
|
||||
// palette preview
|
||||
updateEffectPreview();
|
||||
_autoGenerateCSSName();
|
||||
}
|
||||
|
||||
/* ── Color Cycle helpers ──────────────────────────────────────── */
|
||||
@@ -594,10 +594,25 @@ function _loadCompositeState(css) {
|
||||
|
||||
let _mappedZones = [];
|
||||
let _mappedAvailableSources = []; // non-mapped sources for zone dropdowns
|
||||
let _mappedZoneEntitySelects = [];
|
||||
|
||||
function _getMappedSourceItems() {
|
||||
return _mappedAvailableSources.map(s => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
icon: getColorStripIcon(s.source_type),
|
||||
}));
|
||||
}
|
||||
|
||||
function _mappedDestroyEntitySelects() {
|
||||
_mappedZoneEntitySelects.forEach(es => es.destroy());
|
||||
_mappedZoneEntitySelects = [];
|
||||
}
|
||||
|
||||
function _mappedRenderList() {
|
||||
const list = document.getElementById('mapped-zones-list');
|
||||
if (!list) return;
|
||||
_mappedDestroyEntitySelects();
|
||||
list.innerHTML = _mappedZones.map((zone, i) => {
|
||||
const srcOptions = _mappedAvailableSources.map(s =>
|
||||
`<option value="${s.id}"${zone.source_id === s.id ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
@@ -627,6 +642,15 @@ function _mappedRenderList() {
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Attach EntitySelect to each zone's source dropdown
|
||||
list.querySelectorAll('.mapped-zone-source').forEach(sel => {
|
||||
_mappedZoneEntitySelects.push(new EntitySelect({
|
||||
target: sel,
|
||||
getItems: _getMappedSourceItems,
|
||||
placeholder: t('color_strip.mapped.select_source'),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export function mappedAddZone() {
|
||||
@@ -703,6 +727,7 @@ export function onAudioVizChange() {
|
||||
document.getElementById('css-editor-audio-color-peak-group').style.display = viz === 'vu_meter' ? '' : 'none';
|
||||
// Mirror: spectrum only
|
||||
document.getElementById('css-editor-audio-mirror-group').style.display = viz === 'spectrum' ? '' : 'none';
|
||||
_autoGenerateCSSName();
|
||||
}
|
||||
|
||||
async function _loadAudioSources() {
|
||||
@@ -900,6 +925,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
const isAudio = source.source_type === 'audio';
|
||||
const isApiInput = source.source_type === 'api_input';
|
||||
const isNotification = source.source_type === 'notification';
|
||||
const isPictureAdvanced = source.source_type === 'picture_advanced';
|
||||
|
||||
// Clock crosslink badge (replaces speed badge when clock is assigned)
|
||||
const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null;
|
||||
@@ -976,14 +1002,12 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
`;
|
||||
} else if (isAudio) {
|
||||
const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum';
|
||||
const sensitivityVal = (source.sensitivity || 1.0).toFixed(1);
|
||||
const vizMode = source.visualization_mode || 'spectrum';
|
||||
const showPalette = (vizMode === 'spectrum' || vizMode === 'beat_pulse') && source.palette;
|
||||
const audioPaletteLabel = showPalette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">${ICON_MUSIC} ${escapeHtml(vizLabel)}</span>
|
||||
${audioPaletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.audio.palette')}">${ICON_PALETTE} ${escapeHtml(audioPaletteLabel)}</span>` : ''}
|
||||
<span class="stream-card-prop" title="${t('color_strip.audio.sensitivity')}">${ICON_ACTIVITY} ${sensitivityVal}</span>
|
||||
${source.audio_source_id ? (() => {
|
||||
const as = audioSourceMap && audioSourceMap[source.audio_source_id];
|
||||
const asName = as ? as.name : source.audio_source_id;
|
||||
@@ -1014,6 +1038,22 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
</span>
|
||||
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
|
||||
`;
|
||||
} else if (isPictureAdvanced) {
|
||||
const cal = source.calibration || {};
|
||||
const lines = cal.lines || [];
|
||||
const totalLeds = lines.reduce((s, l) => s + (l.led_count || 0), 0);
|
||||
const ledCount = (source.led_count > 0) ? source.led_count : totalLeds;
|
||||
// Collect unique picture source names
|
||||
const psIds = [...new Set(lines.map(l => l.picture_source_id).filter(Boolean))];
|
||||
const psNames = psIds.map(id => {
|
||||
const ps = pictureSourceMap && pictureSourceMap[id];
|
||||
return ps ? ps.name : id;
|
||||
});
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop" title="${t('calibration.advanced.lines_title')}">${ICON_MAP_PIN} ${lines.length} ${t('calibration.advanced.lines_title').toLowerCase()}</span>
|
||||
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${ledCount}</span>` : ''}
|
||||
${psNames.length ? `<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">${ICON_LINK_SOURCE} ${escapeHtml(psNames.join(', '))}</span>` : ''}
|
||||
`;
|
||||
} else {
|
||||
const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id];
|
||||
const srcName = ps ? ps.name : source.picture_source_id || '—';
|
||||
@@ -1032,8 +1072,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
}
|
||||
|
||||
const icon = getColorStripIcon(source.source_type);
|
||||
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification)
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
|
||||
const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification);
|
||||
const calibrationBtn = isPictureKind
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="${isPictureAdvanced ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
|
||||
: '';
|
||||
|
||||
return wrapCard({
|
||||
@@ -1057,6 +1098,33 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Auto-name generation ─────────────────────────────────────── */
|
||||
|
||||
let _cssNameManuallyEdited = false;
|
||||
|
||||
function _autoGenerateCSSName() {
|
||||
if (_cssNameManuallyEdited) return;
|
||||
if (document.getElementById('css-editor-id').value) return; // edit mode
|
||||
const type = document.getElementById('css-editor-type').value;
|
||||
const typeLabel = t(`color_strip.type.${type}`);
|
||||
let detail = '';
|
||||
if (type === 'picture') {
|
||||
const sel = document.getElementById('css-editor-picture-source');
|
||||
const name = sel?.selectedOptions[0]?.textContent?.trim();
|
||||
if (name) detail = name;
|
||||
} else if (type === 'effect') {
|
||||
const eff = document.getElementById('css-editor-effect-type').value;
|
||||
if (eff) detail = t(`color_strip.effect.${eff}`);
|
||||
} else if (type === 'audio') {
|
||||
const viz = document.getElementById('css-editor-audio-viz').value;
|
||||
if (viz) detail = t(`color_strip.audio.viz.${viz}`);
|
||||
} else if (type === 'notification') {
|
||||
const eff = document.getElementById('css-editor-notification-effect').value;
|
||||
if (eff) detail = t(`color_strip.notification.effect.${eff}`);
|
||||
}
|
||||
document.getElementById('css-editor-name').value = detail ? `${typeLabel} · ${detail}` : typeLabel;
|
||||
}
|
||||
|
||||
/* ── Editor open/close ────────────────────────────────────────── */
|
||||
|
||||
export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
@@ -1149,9 +1217,10 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
} else if (sourceType === 'notification') {
|
||||
_loadNotificationState(css);
|
||||
} else {
|
||||
sourceSelect.value = css.picture_source_id || '';
|
||||
if (sourceType === 'picture') sourceSelect.value = css.picture_source_id || '';
|
||||
|
||||
document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average';
|
||||
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
|
||||
|
||||
const smoothing = css.smoothing ?? 0.3;
|
||||
document.getElementById('css-editor-smoothing').value = smoothing;
|
||||
@@ -1213,6 +1282,7 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
document.getElementById('css-editor-type').value = 'picture';
|
||||
onCSSTypeChange();
|
||||
document.getElementById('css-editor-interpolation').value = 'average';
|
||||
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
|
||||
document.getElementById('css-editor-smoothing').value = 0.3;
|
||||
document.getElementById('css-editor-smoothing-value').textContent = '0.30';
|
||||
document.getElementById('css-editor-brightness').value = 1.0;
|
||||
@@ -1248,8 +1318,15 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
{ position: 0.0, color: [255, 0, 0] },
|
||||
{ position: 1.0, color: [0, 0, 255] },
|
||||
]);
|
||||
_autoGenerateCSSName();
|
||||
}
|
||||
|
||||
// Auto-name wiring
|
||||
_cssNameManuallyEdited = !!(cssId || cloneData);
|
||||
document.getElementById('css-editor-name').oninput = () => { _cssNameManuallyEdited = true; };
|
||||
document.getElementById('css-editor-picture-source').onchange = () => _autoGenerateCSSName();
|
||||
document.getElementById('css-editor-notification-effect').onchange = () => _autoGenerateCSSName();
|
||||
|
||||
document.getElementById('css-editor-error').style.display = 'none';
|
||||
cssEditorModal.snapshot();
|
||||
cssEditorModal.open();
|
||||
@@ -1385,6 +1462,18 @@ export async function saveCSSEditor() {
|
||||
app_colors: _notificationGetAppColorsDict(),
|
||||
};
|
||||
if (!cssId) payload.source_type = 'notification';
|
||||
} else if (sourceType === 'picture_advanced') {
|
||||
payload = {
|
||||
name,
|
||||
interpolation_mode: document.getElementById('css-editor-interpolation').value,
|
||||
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
|
||||
brightness: parseFloat(document.getElementById('css-editor-brightness').value),
|
||||
saturation: parseFloat(document.getElementById('css-editor-saturation').value),
|
||||
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
|
||||
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
|
||||
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
|
||||
};
|
||||
if (!cssId) payload.source_type = 'picture_advanced';
|
||||
} else {
|
||||
payload = {
|
||||
name,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../core/icons.js';
|
||||
import { scenePresetsCache } from '../core/state.js';
|
||||
import { cardColorStyle, cardColorButton } from '../core/card-colors.js';
|
||||
import { EntityPalette } from '../core/entity-palette.js';
|
||||
|
||||
let _editingId = null;
|
||||
let _allTargets = []; // fetched on capture open
|
||||
@@ -153,13 +154,37 @@ export async function editScenePreset(presetId) {
|
||||
document.getElementById('scene-preset-editor-description').value = preset.description || '';
|
||||
document.getElementById('scene-preset-editor-error').style.display = 'none';
|
||||
|
||||
// Hide target selector in edit mode (metadata only)
|
||||
const selectorGroup = document.getElementById('scene-target-selector-group');
|
||||
if (selectorGroup) selectorGroup.style.display = 'none';
|
||||
|
||||
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
|
||||
if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.edit'); titleEl.textContent = t('scenes.edit'); }
|
||||
|
||||
// Show target selector and pre-populate with existing targets
|
||||
const selectorGroup = document.getElementById('scene-target-selector-group');
|
||||
const targetList = document.getElementById('scene-target-list');
|
||||
if (selectorGroup && targetList) {
|
||||
selectorGroup.style.display = '';
|
||||
targetList.innerHTML = '';
|
||||
try {
|
||||
const resp = await fetchWithAuth('/output-targets');
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
_allTargets = data.targets || [];
|
||||
|
||||
// Pre-add targets already in the preset
|
||||
const presetTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
|
||||
for (const tid of presetTargetIds) {
|
||||
const tgt = _allTargets.find(t => t.id === tid);
|
||||
if (!tgt) continue;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'scene-target-item';
|
||||
item.dataset.targetId = tid;
|
||||
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">✕</button>`;
|
||||
targetList.appendChild(item);
|
||||
}
|
||||
_refreshTargetSelect();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
scenePresetModal.open();
|
||||
scenePresetModal.snapshot();
|
||||
}
|
||||
@@ -180,9 +205,11 @@ export async function saveScenePreset() {
|
||||
try {
|
||||
let resp;
|
||||
if (_editingId) {
|
||||
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => el.dataset.targetId);
|
||||
resp = await fetchWithAuth(`/scene-presets/${_editingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description }),
|
||||
body: JSON.stringify({ name, description, target_ids }),
|
||||
});
|
||||
} else {
|
||||
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
@@ -216,42 +243,54 @@ export async function closeScenePresetEditor() {
|
||||
|
||||
// ===== Target selector helpers =====
|
||||
|
||||
function _refreshTargetSelect() {
|
||||
const select = document.getElementById('scene-target-select');
|
||||
if (!select) return;
|
||||
const added = new Set(
|
||||
function _getAddedTargetIds() {
|
||||
return new Set(
|
||||
[...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => el.dataset.targetId)
|
||||
);
|
||||
select.innerHTML = '';
|
||||
for (const tgt of _allTargets) {
|
||||
if (added.has(tgt.id)) continue;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = tgt.id;
|
||||
opt.textContent = tgt.name;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
// Disable add button when no targets available
|
||||
const addBtn = select.parentElement?.querySelector('button');
|
||||
if (addBtn) addBtn.disabled = select.options.length === 0;
|
||||
}
|
||||
|
||||
export function addSceneTarget() {
|
||||
const select = document.getElementById('scene-target-select');
|
||||
function _refreshTargetSelect() {
|
||||
// Update add button disabled state
|
||||
const addBtn = document.getElementById('scene-target-add-btn');
|
||||
if (addBtn) {
|
||||
const added = _getAddedTargetIds();
|
||||
addBtn.disabled = _allTargets.every(t => added.has(t.id));
|
||||
}
|
||||
}
|
||||
|
||||
function _addTargetToList(targetId, targetName) {
|
||||
const list = document.getElementById('scene-target-list');
|
||||
if (!select || !list || !select.value) return;
|
||||
|
||||
const targetId = select.value;
|
||||
const targetName = select.options[select.selectedIndex].text;
|
||||
|
||||
if (!list) return;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'scene-target-item';
|
||||
item.dataset.targetId = targetId;
|
||||
item.innerHTML = `<span>${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">✕</button>`;
|
||||
item.innerHTML = `<span>${ICON_TARGET} ${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">✕</button>`;
|
||||
list.appendChild(item);
|
||||
_refreshTargetSelect();
|
||||
}
|
||||
|
||||
export async function addSceneTarget() {
|
||||
const added = _getAddedTargetIds();
|
||||
const available = _allTargets.filter(t => !added.has(t.id));
|
||||
if (available.length === 0) return;
|
||||
|
||||
const items = available.map(t => ({
|
||||
value: t.id,
|
||||
label: t.name,
|
||||
icon: ICON_TARGET,
|
||||
}));
|
||||
|
||||
const picked = await EntityPalette.pick({
|
||||
items,
|
||||
placeholder: t('scenes.targets.search_placeholder'),
|
||||
});
|
||||
if (!picked) return;
|
||||
|
||||
const tgt = _allTargets.find(t => t.id === picked);
|
||||
if (tgt) _addTargetToList(tgt.id, tgt.name);
|
||||
}
|
||||
|
||||
export function removeSceneTarget(btn) {
|
||||
btn.closest('.scene-target-item').remove();
|
||||
_refreshTargetSelect();
|
||||
|
||||
@@ -624,10 +624,13 @@
|
||||
"automations.conditions.add": "Add Condition",
|
||||
"automations.conditions.empty": "No conditions — automation is always active when enabled",
|
||||
"automations.condition.always": "Always",
|
||||
"automations.condition.always.desc": "Always active",
|
||||
"automations.condition.always.hint": "Automation activates immediately when enabled and stays active.",
|
||||
"automations.condition.startup": "Startup",
|
||||
"automations.condition.startup.desc": "On server start",
|
||||
"automations.condition.startup.hint": "Activates when the server starts and stays active while enabled.",
|
||||
"automations.condition.application": "Application",
|
||||
"automations.condition.application.desc": "App running/focused",
|
||||
"automations.condition.application.apps": "Applications:",
|
||||
"automations.condition.application.apps.hint": "Process names, one per line (e.g. firefox.exe)",
|
||||
"automations.condition.application.browse": "Browse",
|
||||
@@ -636,23 +639,31 @@
|
||||
"automations.condition.application.match_type": "Match Type:",
|
||||
"automations.condition.application.match_type.hint": "How to detect the application",
|
||||
"automations.condition.application.match_type.running": "Running",
|
||||
"automations.condition.application.match_type.topmost": "Topmost (foreground)",
|
||||
"automations.condition.application.match_type.topmost_fullscreen": "Topmost + Fullscreen",
|
||||
"automations.condition.application.match_type.running.desc": "Process is active",
|
||||
"automations.condition.application.match_type.topmost": "Topmost",
|
||||
"automations.condition.application.match_type.topmost.desc": "Foreground window",
|
||||
"automations.condition.application.match_type.topmost_fullscreen": "Topmost + FS",
|
||||
"automations.condition.application.match_type.topmost_fullscreen.desc": "Foreground + fullscreen",
|
||||
"automations.condition.application.match_type.fullscreen": "Fullscreen",
|
||||
"automations.condition.application.match_type.fullscreen.desc": "Any fullscreen app",
|
||||
"automations.condition.time_of_day": "Time of Day",
|
||||
"automations.condition.time_of_day.desc": "Time range",
|
||||
"automations.condition.time_of_day.start_time": "Start Time:",
|
||||
"automations.condition.time_of_day.end_time": "End Time:",
|
||||
"automations.condition.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:00–06:00), set start time after end time.",
|
||||
"automations.condition.system_idle": "System Idle",
|
||||
"automations.condition.system_idle.desc": "User idle/active",
|
||||
"automations.condition.system_idle.idle_minutes": "Idle Timeout (minutes):",
|
||||
"automations.condition.system_idle.mode": "Trigger Mode:",
|
||||
"automations.condition.system_idle.when_idle": "When idle",
|
||||
"automations.condition.system_idle.when_active": "When active",
|
||||
"automations.condition.display_state": "Display State",
|
||||
"automations.condition.display_state.desc": "Monitor on/off",
|
||||
"automations.condition.display_state.state": "Monitor State:",
|
||||
"automations.condition.display_state.on": "On",
|
||||
"automations.condition.display_state.off": "Off (sleeping)",
|
||||
"automations.condition.mqtt": "MQTT",
|
||||
"automations.condition.mqtt.desc": "MQTT message",
|
||||
"automations.condition.mqtt.topic": "Topic:",
|
||||
"automations.condition.mqtt.payload": "Payload:",
|
||||
"automations.condition.mqtt.match_mode": "Match Mode:",
|
||||
@@ -661,6 +672,7 @@
|
||||
"automations.condition.mqtt.match_mode.regex": "Regex",
|
||||
"automations.condition.mqtt.hint": "Activate when an MQTT topic receives a matching payload",
|
||||
"automations.condition.webhook": "Webhook",
|
||||
"automations.condition.webhook.desc": "HTTP callback",
|
||||
"automations.condition.webhook.hint": "Activate via an HTTP call from external services (Home Assistant, IFTTT, curl, etc.)",
|
||||
"automations.condition.webhook.url": "Webhook URL:",
|
||||
"automations.condition.webhook.copy": "Copy",
|
||||
@@ -673,9 +685,12 @@
|
||||
"automations.scene.none_available": "No scenes available",
|
||||
"automations.deactivation_mode": "Deactivation:",
|
||||
"automations.deactivation_mode.hint": "What happens when conditions stop matching",
|
||||
"automations.deactivation_mode.none": "None — keep current state",
|
||||
"automations.deactivation_mode.revert": "Revert to previous state",
|
||||
"automations.deactivation_mode.fallback_scene": "Activate fallback scene",
|
||||
"automations.deactivation_mode.none": "None",
|
||||
"automations.deactivation_mode.none.desc": "Keep current state",
|
||||
"automations.deactivation_mode.revert": "Revert",
|
||||
"automations.deactivation_mode.revert.desc": "Restore previous state",
|
||||
"automations.deactivation_mode.fallback_scene": "Fallback",
|
||||
"automations.deactivation_mode.fallback_scene.desc": "Activate a fallback scene",
|
||||
"automations.deactivation_scene": "Fallback Scene:",
|
||||
"automations.deactivation_scene.hint": "Scene to activate when this automation deactivates",
|
||||
"automations.status.active": "Active",
|
||||
@@ -702,6 +717,8 @@
|
||||
"scenes.description.hint": "Optional description of what this scene does",
|
||||
"scenes.targets": "Targets:",
|
||||
"scenes.targets.hint": "Select which targets to include in this scene snapshot",
|
||||
"scenes.targets.add": "Add Target",
|
||||
"scenes.targets.search_placeholder": "Search targets...",
|
||||
"scenes.capture": "Capture",
|
||||
"scenes.activate": "Activate scene",
|
||||
"scenes.recapture": "Recapture current state",
|
||||
@@ -747,6 +764,9 @@
|
||||
"color_strip.interpolation.average": "Average",
|
||||
"color_strip.interpolation.median": "Median",
|
||||
"color_strip.interpolation.dominant": "Dominant",
|
||||
"color_strip.interpolation.average.desc": "Blend all sampled pixels into a smooth color",
|
||||
"color_strip.interpolation.median.desc": "Pick the middle color, reducing outliers",
|
||||
"color_strip.interpolation.dominant.desc": "Use the most frequent color in the sample",
|
||||
"color_strip.smoothing": "Smoothing:",
|
||||
"color_strip.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
||||
"color_strip.frame_interpolation": "Frame Interpolation:",
|
||||
@@ -773,6 +793,8 @@
|
||||
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers. Audio Reactive drives LEDs from real-time audio input. API Input receives raw LED colors from external clients via REST or WebSocket.",
|
||||
"color_strip.type.picture": "Picture Source",
|
||||
"color_strip.type.picture.desc": "Colors from screen capture",
|
||||
"color_strip.type.picture_advanced": "Multi-Monitor",
|
||||
"color_strip.type.picture_advanced.desc": "Line-based calibration across monitors",
|
||||
"color_strip.type.static": "Static Color",
|
||||
"color_strip.type.static.desc": "Single solid color fill",
|
||||
"color_strip.type.gradient": "Gradient",
|
||||
@@ -910,6 +932,7 @@
|
||||
"color_strip.mapped.zone_end": "End LED",
|
||||
"color_strip.mapped.zone_reverse": "Reverse",
|
||||
"color_strip.mapped.zones_count": "zones",
|
||||
"color_strip.mapped.select_source": "Search sources...",
|
||||
"color_strip.mapped.error.no_source": "Each zone must have a source selected",
|
||||
"color_strip.audio.visualization": "Visualization:",
|
||||
"color_strip.audio.visualization.hint": "How audio data is rendered to LEDs.",
|
||||
@@ -1197,6 +1220,29 @@
|
||||
"calibration.error.save_failed": "Failed to save calibration",
|
||||
"calibration.error.led_count_mismatch": "Total LEDs must equal the device LED count",
|
||||
"calibration.error.led_count_exceeded": "Calibrated LEDs exceed the total LED count",
|
||||
"calibration.mode.simple": "Simple",
|
||||
"calibration.mode.advanced": "Advanced",
|
||||
"calibration.switch_to_advanced": "Switch to Advanced",
|
||||
"calibration.advanced.title": "Advanced Calibration",
|
||||
"calibration.advanced.switch_to_simple": "Switch to Simple",
|
||||
"calibration.advanced.lines_title": "Lines",
|
||||
"calibration.advanced.canvas_hint": "Drag monitors to reposition. Click edges to select lines. Scroll to zoom, drag empty space to pan.",
|
||||
"calibration.advanced.reset_view": "Reset view",
|
||||
"calibration.advanced.line_properties": "Line Properties",
|
||||
"calibration.advanced.picture_source": "Source:",
|
||||
"calibration.advanced.picture_source.hint": "The picture source (monitor) this line samples from",
|
||||
"calibration.advanced.edge": "Edge:",
|
||||
"calibration.advanced.edge.hint": "Which screen edge to sample pixels from",
|
||||
"calibration.advanced.led_count": "LEDs:",
|
||||
"calibration.advanced.led_count.hint": "Number of LEDs mapped to this line",
|
||||
"calibration.advanced.span_start": "Span Start:",
|
||||
"calibration.advanced.span_start.hint": "Where sampling begins along the edge (0 = start, 1 = end). Use to cover only part of an edge.",
|
||||
"calibration.advanced.span_end": "Span End:",
|
||||
"calibration.advanced.span_end.hint": "Where sampling ends along the edge (0 = start, 1 = end). Together with Span Start, defines the active portion.",
|
||||
"calibration.advanced.border_width": "Depth (px):",
|
||||
"calibration.advanced.border_width.hint": "How many pixels deep from the edge to sample. Larger values capture more of the screen interior.",
|
||||
"calibration.advanced.reverse": "Reverse",
|
||||
"calibration.advanced.no_lines_warning": "Add at least one line",
|
||||
"dashboard.error.automation_toggle_failed": "Failed to toggle automation",
|
||||
"dashboard.error.start_failed": "Failed to start processing",
|
||||
"dashboard.error.stop_failed": "Failed to stop processing",
|
||||
|
||||
@@ -624,10 +624,13 @@
|
||||
"automations.conditions.add": "Добавить условие",
|
||||
"automations.conditions.empty": "Нет условий — автоматизация всегда активна когда включена",
|
||||
"automations.condition.always": "Всегда",
|
||||
"automations.condition.always.desc": "Всегда активно",
|
||||
"automations.condition.always.hint": "Автоматизация активируется сразу при включении и остаётся активной.",
|
||||
"automations.condition.startup": "Автозапуск",
|
||||
"automations.condition.startup.desc": "При запуске сервера",
|
||||
"automations.condition.startup.hint": "Активируется при запуске сервера и остаётся активной пока включена.",
|
||||
"automations.condition.application": "Приложение",
|
||||
"automations.condition.application.desc": "Приложение запущено",
|
||||
"automations.condition.application.apps": "Приложения:",
|
||||
"automations.condition.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)",
|
||||
"automations.condition.application.browse": "Обзор",
|
||||
@@ -636,23 +639,31 @@
|
||||
"automations.condition.application.match_type": "Тип соответствия:",
|
||||
"automations.condition.application.match_type.hint": "Как определять наличие приложения",
|
||||
"automations.condition.application.match_type.running": "Запущено",
|
||||
"automations.condition.application.match_type.running.desc": "Процесс активен",
|
||||
"automations.condition.application.match_type.topmost": "На переднем плане",
|
||||
"automations.condition.application.match_type.topmost_fullscreen": "На переднем плане + Полный экран",
|
||||
"automations.condition.application.match_type.topmost.desc": "Окно в фокусе",
|
||||
"automations.condition.application.match_type.topmost_fullscreen": "Передний план + ПЭ",
|
||||
"automations.condition.application.match_type.topmost_fullscreen.desc": "В фокусе + полный экран",
|
||||
"automations.condition.application.match_type.fullscreen": "Полный экран",
|
||||
"automations.condition.application.match_type.fullscreen.desc": "Любое полноэкранное",
|
||||
"automations.condition.time_of_day": "Время суток",
|
||||
"automations.condition.time_of_day.desc": "Диапазон времени",
|
||||
"automations.condition.time_of_day.start_time": "Время начала:",
|
||||
"automations.condition.time_of_day.end_time": "Время окончания:",
|
||||
"automations.condition.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06:00) укажите время начала позже времени окончания.",
|
||||
"automations.condition.system_idle": "Бездействие системы",
|
||||
"automations.condition.system_idle.desc": "Бездействие/активность",
|
||||
"automations.condition.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):",
|
||||
"automations.condition.system_idle.mode": "Режим срабатывания:",
|
||||
"automations.condition.system_idle.when_idle": "При бездействии",
|
||||
"automations.condition.system_idle.when_active": "При активности",
|
||||
"automations.condition.display_state": "Состояние дисплея",
|
||||
"automations.condition.display_state.desc": "Монитор вкл/выкл",
|
||||
"automations.condition.display_state.state": "Состояние монитора:",
|
||||
"automations.condition.display_state.on": "Включён",
|
||||
"automations.condition.display_state.off": "Выключен (спящий режим)",
|
||||
"automations.condition.mqtt": "MQTT",
|
||||
"automations.condition.mqtt.desc": "MQTT сообщение",
|
||||
"automations.condition.mqtt.topic": "Топик:",
|
||||
"automations.condition.mqtt.payload": "Значение:",
|
||||
"automations.condition.mqtt.match_mode": "Режим сравнения:",
|
||||
@@ -661,6 +672,7 @@
|
||||
"automations.condition.mqtt.match_mode.regex": "Регулярное выражение",
|
||||
"automations.condition.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику",
|
||||
"automations.condition.webhook": "Вебхук",
|
||||
"automations.condition.webhook.desc": "HTTP вызов",
|
||||
"automations.condition.webhook.hint": "Активировать через HTTP-запрос от внешних сервисов (Home Assistant, IFTTT, curl и т.д.)",
|
||||
"automations.condition.webhook.url": "URL вебхука:",
|
||||
"automations.condition.webhook.copy": "Скопировать",
|
||||
@@ -673,9 +685,12 @@
|
||||
"automations.scene.none_available": "Нет доступных сцен",
|
||||
"automations.deactivation_mode": "Деактивация:",
|
||||
"automations.deactivation_mode.hint": "Что происходит, когда условия перестают выполняться",
|
||||
"automations.deactivation_mode.none": "Ничего — оставить текущее состояние",
|
||||
"automations.deactivation_mode.revert": "Вернуть предыдущее состояние",
|
||||
"automations.deactivation_mode.fallback_scene": "Активировать резервную сцену",
|
||||
"automations.deactivation_mode.none": "Ничего",
|
||||
"automations.deactivation_mode.none.desc": "Оставить текущее состояние",
|
||||
"automations.deactivation_mode.revert": "Откатить",
|
||||
"automations.deactivation_mode.revert.desc": "Вернуть предыдущее состояние",
|
||||
"automations.deactivation_mode.fallback_scene": "Резервная",
|
||||
"automations.deactivation_mode.fallback_scene.desc": "Активировать резервную сцену",
|
||||
"automations.deactivation_scene": "Резервная сцена:",
|
||||
"automations.deactivation_scene.hint": "Сцена для активации при деактивации автоматизации",
|
||||
"automations.status.active": "Активна",
|
||||
@@ -702,6 +717,8 @@
|
||||
"scenes.description.hint": "Необязательное описание назначения этой сцены",
|
||||
"scenes.targets": "Цели:",
|
||||
"scenes.targets.hint": "Выберите какие цели включить в снимок сцены",
|
||||
"scenes.targets.add": "Добавить цель",
|
||||
"scenes.targets.search_placeholder": "Поиск целей...",
|
||||
"scenes.capture": "Захват",
|
||||
"scenes.activate": "Активировать сцену",
|
||||
"scenes.recapture": "Перезахватить текущее состояние",
|
||||
@@ -747,6 +764,9 @@
|
||||
"color_strip.interpolation.average": "Среднее",
|
||||
"color_strip.interpolation.median": "Медиана",
|
||||
"color_strip.interpolation.dominant": "Доминирующий",
|
||||
"color_strip.interpolation.average.desc": "Смешивает все пиксели в усреднённый цвет",
|
||||
"color_strip.interpolation.median.desc": "Берёт средний цвет, игнорируя выбросы",
|
||||
"color_strip.interpolation.dominant.desc": "Использует самый частый цвет в выборке",
|
||||
"color_strip.smoothing": "Сглаживание:",
|
||||
"color_strip.smoothing.hint": "Временное смешивание кадров (0=без смешивания, 1=полное). Уменьшает мерцание.",
|
||||
"color_strip.frame_interpolation": "Интерполяция кадров:",
|
||||
@@ -773,6 +793,8 @@
|
||||
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои. Аудиореактив управляет LED от аудиосигнала в реальном времени. API-ввод принимает массивы цветов LED от внешних клиентов через REST или WebSocket.",
|
||||
"color_strip.type.picture": "Источник изображения",
|
||||
"color_strip.type.picture.desc": "Цвета из захвата экрана",
|
||||
"color_strip.type.picture_advanced": "Мультимонитор",
|
||||
"color_strip.type.picture_advanced.desc": "Калибровка линиями по нескольким мониторам",
|
||||
"color_strip.type.static": "Статический цвет",
|
||||
"color_strip.type.static.desc": "Заливка одним цветом",
|
||||
"color_strip.type.gradient": "Градиент",
|
||||
@@ -910,6 +932,7 @@
|
||||
"color_strip.mapped.zone_end": "Конец LED",
|
||||
"color_strip.mapped.zone_reverse": "Реверс",
|
||||
"color_strip.mapped.zones_count": "зон",
|
||||
"color_strip.mapped.select_source": "Поиск источников...",
|
||||
"color_strip.mapped.error.no_source": "Для каждой зоны должен быть выбран источник",
|
||||
"color_strip.audio.visualization": "Визуализация:",
|
||||
"color_strip.audio.visualization.hint": "Способ отображения аудиоданных на LED.",
|
||||
@@ -1197,6 +1220,29 @@
|
||||
"calibration.error.save_failed": "Не удалось сохранить калибровку",
|
||||
"calibration.error.led_count_mismatch": "Общее количество LED должно совпадать с количеством LED устройства",
|
||||
"calibration.error.led_count_exceeded": "Калиброванных LED больше, чем общее количество LED",
|
||||
"calibration.mode.simple": "Простой",
|
||||
"calibration.mode.advanced": "Расширенный",
|
||||
"calibration.switch_to_advanced": "Расширенный режим",
|
||||
"calibration.advanced.title": "Расширенная калибровка",
|
||||
"calibration.advanced.switch_to_simple": "Простой режим",
|
||||
"calibration.advanced.lines_title": "Линии",
|
||||
"calibration.advanced.canvas_hint": "Перетаскивайте мониторы. Нажимайте на грани для выбора линий. Прокрутка — масштаб, перетаскивание пустого места — сдвиг.",
|
||||
"calibration.advanced.reset_view": "Сбросить вид",
|
||||
"calibration.advanced.line_properties": "Свойства линии",
|
||||
"calibration.advanced.picture_source": "Источник:",
|
||||
"calibration.advanced.picture_source.hint": "Источник изображения (монитор), с которого эта линия снимает данные",
|
||||
"calibration.advanced.edge": "Грань:",
|
||||
"calibration.advanced.edge.hint": "С какой грани экрана снимать пиксели",
|
||||
"calibration.advanced.led_count": "Светодиоды:",
|
||||
"calibration.advanced.led_count.hint": "Количество светодиодов на этой линии",
|
||||
"calibration.advanced.span_start": "Начало:",
|
||||
"calibration.advanced.span_start.hint": "Откуда начинается захват вдоль грани (0 = начало, 1 = конец). Позволяет покрыть только часть грани.",
|
||||
"calibration.advanced.span_end": "Конец:",
|
||||
"calibration.advanced.span_end.hint": "Где заканчивается захват вдоль грани (0 = начало, 1 = конец). Вместе с «Начало» определяет активный участок.",
|
||||
"calibration.advanced.border_width": "Глубина (пкс):",
|
||||
"calibration.advanced.border_width.hint": "Сколько пикселей вглубь от края захватывать. Большие значения берут больше внутренней части экрана.",
|
||||
"calibration.advanced.reverse": "Реверс",
|
||||
"calibration.advanced.no_lines_warning": "Добавьте хотя бы одну линию",
|
||||
"dashboard.error.automation_toggle_failed": "Не удалось переключить автоматизацию",
|
||||
"dashboard.error.start_failed": "Не удалось запустить обработку",
|
||||
"dashboard.error.stop_failed": "Не удалось остановить обработку",
|
||||
|
||||
@@ -624,10 +624,13 @@
|
||||
"automations.conditions.add": "添加条件",
|
||||
"automations.conditions.empty": "无条件 — 启用后自动化始终处于活动状态",
|
||||
"automations.condition.always": "始终",
|
||||
"automations.condition.always.desc": "始终活跃",
|
||||
"automations.condition.always.hint": "自动化启用后立即激活并保持活动。",
|
||||
"automations.condition.startup": "启动",
|
||||
"automations.condition.startup.desc": "服务器启动时",
|
||||
"automations.condition.startup.hint": "服务器启动时激活,启用期间保持活动。",
|
||||
"automations.condition.application": "应用程序",
|
||||
"automations.condition.application.desc": "应用运行/聚焦",
|
||||
"automations.condition.application.apps": "应用程序:",
|
||||
"automations.condition.application.apps.hint": "进程名,每行一个(例如 firefox.exe)",
|
||||
"automations.condition.application.browse": "浏览",
|
||||
@@ -636,23 +639,31 @@
|
||||
"automations.condition.application.match_type": "匹配类型:",
|
||||
"automations.condition.application.match_type.hint": "如何检测应用程序",
|
||||
"automations.condition.application.match_type.running": "运行中",
|
||||
"automations.condition.application.match_type.topmost": "最前(前台)",
|
||||
"automations.condition.application.match_type.running.desc": "进程活跃",
|
||||
"automations.condition.application.match_type.topmost": "最前",
|
||||
"automations.condition.application.match_type.topmost.desc": "前台窗口",
|
||||
"automations.condition.application.match_type.topmost_fullscreen": "最前 + 全屏",
|
||||
"automations.condition.application.match_type.topmost_fullscreen.desc": "前台 + 全屏",
|
||||
"automations.condition.application.match_type.fullscreen": "全屏",
|
||||
"automations.condition.application.match_type.fullscreen.desc": "任意全屏应用",
|
||||
"automations.condition.time_of_day": "时段",
|
||||
"automations.condition.time_of_day.desc": "时间范围",
|
||||
"automations.condition.time_of_day.start_time": "开始时间:",
|
||||
"automations.condition.time_of_day.end_time": "结束时间:",
|
||||
"automations.condition.time_of_day.overnight_hint": "跨夜时段(如 22:00–06:00),请将开始时间设为晚于结束时间。",
|
||||
"automations.condition.system_idle": "系统空闲",
|
||||
"automations.condition.system_idle.desc": "空闲/活跃",
|
||||
"automations.condition.system_idle.idle_minutes": "空闲超时(分钟):",
|
||||
"automations.condition.system_idle.mode": "触发模式:",
|
||||
"automations.condition.system_idle.when_idle": "空闲时",
|
||||
"automations.condition.system_idle.when_active": "活跃时",
|
||||
"automations.condition.display_state": "显示器状态",
|
||||
"automations.condition.display_state.desc": "显示器开/关",
|
||||
"automations.condition.display_state.state": "显示器状态:",
|
||||
"automations.condition.display_state.on": "开启",
|
||||
"automations.condition.display_state.off": "关闭(休眠)",
|
||||
"automations.condition.mqtt": "MQTT",
|
||||
"automations.condition.mqtt.desc": "MQTT 消息",
|
||||
"automations.condition.mqtt.topic": "主题:",
|
||||
"automations.condition.mqtt.payload": "消息内容:",
|
||||
"automations.condition.mqtt.match_mode": "匹配模式:",
|
||||
@@ -661,6 +672,7 @@
|
||||
"automations.condition.mqtt.match_mode.regex": "正则表达式",
|
||||
"automations.condition.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活",
|
||||
"automations.condition.webhook": "Webhook",
|
||||
"automations.condition.webhook.desc": "HTTP 回调",
|
||||
"automations.condition.webhook.hint": "通过外部服务的 HTTP 请求激活(Home Assistant、IFTTT、curl 等)",
|
||||
"automations.condition.webhook.url": "Webhook URL:",
|
||||
"automations.condition.webhook.copy": "复制",
|
||||
@@ -673,9 +685,12 @@
|
||||
"automations.scene.none_available": "没有可用的场景",
|
||||
"automations.deactivation_mode": "停用方式:",
|
||||
"automations.deactivation_mode.hint": "条件不再满足时的行为",
|
||||
"automations.deactivation_mode.none": "无 — 保持当前状态",
|
||||
"automations.deactivation_mode.revert": "恢复到之前的状态",
|
||||
"automations.deactivation_mode.fallback_scene": "激活备用场景",
|
||||
"automations.deactivation_mode.none": "无",
|
||||
"automations.deactivation_mode.none.desc": "保持当前状态",
|
||||
"automations.deactivation_mode.revert": "恢复",
|
||||
"automations.deactivation_mode.revert.desc": "恢复到之前的状态",
|
||||
"automations.deactivation_mode.fallback_scene": "备用",
|
||||
"automations.deactivation_mode.fallback_scene.desc": "激活备用场景",
|
||||
"automations.deactivation_scene": "备用场景:",
|
||||
"automations.deactivation_scene.hint": "自动化停用时激活的场景",
|
||||
"automations.status.active": "活动",
|
||||
@@ -702,6 +717,8 @@
|
||||
"scenes.description.hint": "此场景功能的可选描述",
|
||||
"scenes.targets": "目标:",
|
||||
"scenes.targets.hint": "选择要包含在此场景快照中的目标",
|
||||
"scenes.targets.add": "添加目标",
|
||||
"scenes.targets.search_placeholder": "搜索目标...",
|
||||
"scenes.capture": "捕获",
|
||||
"scenes.activate": "激活场景",
|
||||
"scenes.recapture": "重新捕获当前状态",
|
||||
@@ -747,6 +764,9 @@
|
||||
"color_strip.interpolation.average": "平均",
|
||||
"color_strip.interpolation.median": "中位数",
|
||||
"color_strip.interpolation.dominant": "主色",
|
||||
"color_strip.interpolation.average.desc": "将所有采样像素混合为平滑颜色",
|
||||
"color_strip.interpolation.median.desc": "取中间颜色值,减少异常值",
|
||||
"color_strip.interpolation.dominant.desc": "使用样本中出现最频繁的颜色",
|
||||
"color_strip.smoothing": "平滑:",
|
||||
"color_strip.smoothing.hint": "帧间时间混合(0=无,1=完全)。减少闪烁。",
|
||||
"color_strip.frame_interpolation": "帧插值:",
|
||||
@@ -773,6 +793,8 @@
|
||||
"color_strip.type.hint": "图片源从屏幕采集推导 LED 颜色。静态颜色用单一颜色填充所有 LED。渐变在所有 LED 上分布颜色渐变。颜色循环平滑循环用户定义的颜色列表。组合将多个源作为混合图层叠加。音频响应从实时音频输入驱动 LED。API 输入通过 REST 或 WebSocket 从外部客户端接收原始 LED 颜色。",
|
||||
"color_strip.type.picture": "图片源",
|
||||
"color_strip.type.picture.desc": "从屏幕捕获获取颜色",
|
||||
"color_strip.type.picture_advanced": "多显示器",
|
||||
"color_strip.type.picture_advanced.desc": "跨显示器的线条校准",
|
||||
"color_strip.type.static": "静态颜色",
|
||||
"color_strip.type.static.desc": "单色填充",
|
||||
"color_strip.type.gradient": "渐变",
|
||||
@@ -910,6 +932,7 @@
|
||||
"color_strip.mapped.zone_end": "结束 LED",
|
||||
"color_strip.mapped.zone_reverse": "反转",
|
||||
"color_strip.mapped.zones_count": "个区域",
|
||||
"color_strip.mapped.select_source": "搜索源...",
|
||||
"color_strip.mapped.error.no_source": "每个区域必须选择一个源",
|
||||
"color_strip.audio.visualization": "可视化:",
|
||||
"color_strip.audio.visualization.hint": "音频数据如何渲染到 LED。",
|
||||
@@ -1197,6 +1220,29 @@
|
||||
"calibration.error.save_failed": "保存校准失败",
|
||||
"calibration.error.led_count_mismatch": "LED总数必须等于设备LED数量",
|
||||
"calibration.error.led_count_exceeded": "校准的LED超过了LED总数",
|
||||
"calibration.mode.simple": "简单",
|
||||
"calibration.mode.advanced": "高级",
|
||||
"calibration.switch_to_advanced": "切换到高级模式",
|
||||
"calibration.advanced.title": "高级校准",
|
||||
"calibration.advanced.switch_to_simple": "切换到简单模式",
|
||||
"calibration.advanced.lines_title": "线段",
|
||||
"calibration.advanced.canvas_hint": "拖动显示器重新排列。点击边缘选择线段。滚动缩放,拖动空白区域平移。",
|
||||
"calibration.advanced.reset_view": "重置视图",
|
||||
"calibration.advanced.line_properties": "线段属性",
|
||||
"calibration.advanced.picture_source": "来源:",
|
||||
"calibration.advanced.picture_source.hint": "此线段采样的图片来源(显示器)",
|
||||
"calibration.advanced.edge": "边缘:",
|
||||
"calibration.advanced.edge.hint": "从屏幕哪条边缘采样像素",
|
||||
"calibration.advanced.led_count": "LED数:",
|
||||
"calibration.advanced.led_count.hint": "映射到此线段的LED数量",
|
||||
"calibration.advanced.span_start": "起始位置:",
|
||||
"calibration.advanced.span_start.hint": "沿边缘开始采样的位置(0 = 起点,1 = 终点)。用于仅覆盖边缘的一部分。",
|
||||
"calibration.advanced.span_end": "结束位置:",
|
||||
"calibration.advanced.span_end.hint": "沿边缘结束采样的位置(0 = 起点,1 = 终点)。与起始位置一起定义活动区域。",
|
||||
"calibration.advanced.border_width": "深度(像素):",
|
||||
"calibration.advanced.border_width.hint": "从边缘向内采样多少像素。较大的值会捕获更多屏幕内部区域。",
|
||||
"calibration.advanced.reverse": "反转",
|
||||
"calibration.advanced.no_lines_warning": "请至少添加一条线段",
|
||||
"dashboard.error.automation_toggle_failed": "切换自动化失败",
|
||||
"dashboard.error.start_failed": "启动处理失败",
|
||||
"dashboard.error.stop_failed": "停止处理失败",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Navigation: network-first with offline fallback
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'ledgrab-v24';
|
||||
const CACHE_NAME = 'ledgrab-v33';
|
||||
|
||||
// Only pre-cache static assets (no auth required).
|
||||
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
|
||||
@@ -18,6 +18,7 @@ const PRECACHE_URLS = [
|
||||
'/static/css/cards.css',
|
||||
'/static/css/modal.css',
|
||||
'/static/css/calibration.css',
|
||||
'/static/css/advanced-calibration.css',
|
||||
'/static/css/dashboard.css',
|
||||
'/static/css/streams.css',
|
||||
'/static/css/patterns.css',
|
||||
|
||||
@@ -5,7 +5,8 @@ from some input, encapsulating everything needed to drive a physical LED strip:
|
||||
calibration, color correction, smoothing, and FPS.
|
||||
|
||||
Current types:
|
||||
PictureColorStripSource — derives LED colors from a PictureSource (screen capture)
|
||||
PictureColorStripSource — derives LED colors from a single PictureSource (simple 4-edge calibration)
|
||||
AdvancedPictureColorStripSource — line-based calibration across multiple PictureSources
|
||||
StaticColorStripSource — constant solid color fills all LEDs
|
||||
GradientColorStripSource — linear gradient across all LEDs from user-defined color stops
|
||||
ColorCycleColorStripSource — smoothly cycles through a user-defined list of colors
|
||||
@@ -240,11 +241,8 @@ class ColorStripSource:
|
||||
os_listener=bool(data.get("os_listener", False)),
|
||||
)
|
||||
|
||||
# Default: "picture" type
|
||||
return PictureColorStripSource(
|
||||
id=sid, name=name, source_type=source_type,
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id, picture_source_id=data.get("picture_source_id") or "",
|
||||
# Shared picture-type field extraction
|
||||
_picture_kwargs = dict(
|
||||
fps=data.get("fps") or 30,
|
||||
brightness=data["brightness"] if data.get("brightness") is not None else 1.0,
|
||||
saturation=data["saturation"] if data.get("saturation") is not None else 1.0,
|
||||
@@ -256,10 +254,39 @@ class ColorStripSource:
|
||||
frame_interpolation=bool(data.get("frame_interpolation", False)),
|
||||
)
|
||||
|
||||
if source_type == "picture_advanced":
|
||||
return AdvancedPictureColorStripSource(
|
||||
id=sid, name=name, source_type="picture_advanced",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id, **_picture_kwargs,
|
||||
)
|
||||
|
||||
# Default: "picture" type (simple 4-edge calibration)
|
||||
return PictureColorStripSource(
|
||||
id=sid, name=name, source_type=source_type,
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id, picture_source_id=data.get("picture_source_id") or "",
|
||||
**_picture_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _picture_base_to_dict(source, d: dict) -> dict:
|
||||
"""Populate dict with fields common to both picture source types."""
|
||||
d["fps"] = source.fps
|
||||
d["brightness"] = source.brightness
|
||||
d["saturation"] = source.saturation
|
||||
d["gamma"] = source.gamma
|
||||
d["smoothing"] = source.smoothing
|
||||
d["interpolation_mode"] = source.interpolation_mode
|
||||
d["calibration"] = calibration_to_dict(source.calibration)
|
||||
d["led_count"] = source.led_count
|
||||
d["frame_interpolation"] = source.frame_interpolation
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class PictureColorStripSource(ColorStripSource):
|
||||
"""Color strip source driven by a PictureSource (screen capture / static image).
|
||||
"""Color strip source driven by a single PictureSource (simple 4-edge calibration).
|
||||
|
||||
Contains everything required to produce LED color arrays from a picture stream:
|
||||
calibration (LED positions), color correction, smoothing, FPS target.
|
||||
@@ -286,16 +313,38 @@ class PictureColorStripSource(ColorStripSource):
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["picture_source_id"] = self.picture_source_id
|
||||
d["fps"] = self.fps
|
||||
d["brightness"] = self.brightness
|
||||
d["saturation"] = self.saturation
|
||||
d["gamma"] = self.gamma
|
||||
d["smoothing"] = self.smoothing
|
||||
d["interpolation_mode"] = self.interpolation_mode
|
||||
d["calibration"] = calibration_to_dict(self.calibration)
|
||||
d["led_count"] = self.led_count
|
||||
d["frame_interpolation"] = self.frame_interpolation
|
||||
return d
|
||||
return _picture_base_to_dict(self, d)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdvancedPictureColorStripSource(ColorStripSource):
|
||||
"""Color strip source with line-based calibration across multiple picture sources.
|
||||
|
||||
Each calibration line references its own picture source and edge, enabling
|
||||
LED strips that span multiple monitors. No single picture_source_id — the
|
||||
picture sources are defined per-line in the calibration config.
|
||||
"""
|
||||
|
||||
@property
|
||||
def sharable(self) -> bool:
|
||||
"""Picture streams are expensive (screen capture) and safe to share."""
|
||||
return True
|
||||
|
||||
fps: int = 30
|
||||
brightness: float = 1.0
|
||||
saturation: float = 1.0
|
||||
gamma: float = 1.0
|
||||
smoothing: float = 0.3
|
||||
interpolation_mode: str = "average"
|
||||
calibration: CalibrationConfig = field(
|
||||
default_factory=lambda: CalibrationConfig(mode="advanced")
|
||||
)
|
||||
led_count: int = 0
|
||||
frame_interpolation: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
return _picture_base_to_dict(self, d)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Dict, List, Optional
|
||||
|
||||
from wled_controller.core.capture.calibration import CalibrationConfig, calibration_to_dict
|
||||
from wled_controller.storage.color_strip_source import (
|
||||
AdvancedPictureColorStripSource,
|
||||
ApiInputColorStripSource,
|
||||
AudioColorStripSource,
|
||||
ColorCycleColorStripSource,
|
||||
@@ -280,6 +281,27 @@ class ColorStripStore:
|
||||
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
|
||||
os_listener=bool(os_listener) if os_listener is not None else False,
|
||||
)
|
||||
elif source_type == "picture_advanced":
|
||||
if calibration is None:
|
||||
calibration = CalibrationConfig(mode="advanced")
|
||||
source = AdvancedPictureColorStripSource(
|
||||
id=source_id,
|
||||
name=name,
|
||||
source_type="picture_advanced",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
fps=fps,
|
||||
brightness=brightness,
|
||||
saturation=saturation,
|
||||
gamma=gamma,
|
||||
smoothing=smoothing,
|
||||
interpolation_mode=interpolation_mode,
|
||||
calibration=calibration,
|
||||
led_count=led_count,
|
||||
frame_interpolation=frame_interpolation,
|
||||
)
|
||||
else:
|
||||
if calibration is None:
|
||||
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
|
||||
@@ -372,8 +394,8 @@ class ColorStripStore:
|
||||
if clock_id is not None:
|
||||
source.clock_id = clock_id if clock_id else None
|
||||
|
||||
if isinstance(source, PictureColorStripSource):
|
||||
if picture_source_id is not None:
|
||||
if isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
|
||||
if picture_source_id is not None and isinstance(source, PictureColorStripSource):
|
||||
source.picture_source_id = picture_source_id
|
||||
if fps is not None:
|
||||
source.fps = fps
|
||||
|
||||
@@ -84,6 +84,7 @@ class ScenePresetStore:
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
order: Optional[int] = None,
|
||||
targets: Optional[List[TargetSnapshot]] = None,
|
||||
) -> ScenePreset:
|
||||
if preset_id not in self._presets:
|
||||
raise ValueError(f"Scene preset not found: {preset_id}")
|
||||
@@ -99,6 +100,8 @@ class ScenePresetStore:
|
||||
preset.description = description
|
||||
if order is not None:
|
||||
preset.order = order
|
||||
if targets is not None:
|
||||
preset.targets = targets
|
||||
|
||||
preset.updated_at = datetime.utcnow()
|
||||
self._save()
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<link rel="stylesheet" href="/static/css/cards.css">
|
||||
<link rel="stylesheet" href="/static/css/modal.css">
|
||||
<link rel="stylesheet" href="/static/css/calibration.css">
|
||||
<link rel="stylesheet" href="/static/css/advanced-calibration.css">
|
||||
<link rel="stylesheet" href="/static/css/dashboard.css">
|
||||
<link rel="stylesheet" href="/static/css/streams.css">
|
||||
<link rel="stylesheet" href="/static/css/patterns.css">
|
||||
@@ -147,6 +148,7 @@
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
{% include 'modals/calibration.html' %}
|
||||
{% include 'modals/advanced-calibration.html' %}
|
||||
{% include 'modals/device-settings.html' %}
|
||||
{% include 'modals/target-editor.html' %}
|
||||
{% include 'modals/css-editor.html' %}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<!-- Advanced Calibration Modal (multimonitor line-based) -->
|
||||
<div id="advanced-calibration-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="advcal-modal-title">
|
||||
<div class="modal-content" style="max-width: 900px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="advcal-modal-title"><svg class="icon" viewBox="0 0 24 24"><path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/><path d="m14.5 12.5 2-2"/><path d="m11.5 9.5 2-2"/><path d="m8.5 6.5 2-2"/><path d="m17.5 15.5 2-2"/></svg> <span data-i18n="calibration.advanced.title">Advanced Calibration</span></h2>
|
||||
<button class="modal-close-btn" onclick="closeAdvancedCalibration()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="advcal-css-id">
|
||||
|
||||
<!-- Two-column layout: canvas + line list -->
|
||||
<div class="advcal-layout">
|
||||
<!-- Left: Canvas showing monitor rectangles with lines -->
|
||||
<div class="advcal-canvas-panel">
|
||||
<canvas id="advcal-canvas" width="560" height="340"></canvas>
|
||||
<div class="advcal-canvas-hint">
|
||||
<small data-i18n="calibration.advanced.canvas_hint">Drag monitors to reposition. Click edges to select lines. Scroll to zoom, drag empty space to pan.</small>
|
||||
<button class="btn-micro" onclick="resetCalibrationView()" title="Reset view" data-i18n-title="calibration.advanced.reset_view">↺</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Line list -->
|
||||
<div class="advcal-lines-panel">
|
||||
<div id="advcal-line-list" class="advcal-line-list">
|
||||
<!-- Line items rendered dynamically -->
|
||||
</div>
|
||||
<div class="advcal-leds-counter"><span id="advcal-total-leds">0</span> LEDs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected line properties -->
|
||||
<div id="advcal-line-props" class="advcal-line-props" style="display:none">
|
||||
<h3 style="margin: 0 0 8px; font-size: 0.9em;" data-i18n="calibration.advanced.line_properties">Line Properties</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="advcal-line-source" data-i18n="calibration.advanced.picture_source">Source:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.picture_source.hint">The picture source (monitor) this line samples from</small>
|
||||
<select id="advcal-line-source" onchange="updateCalibrationLine()"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="advcal-line-edge" data-i18n="calibration.advanced.edge">Edge:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.edge.hint">Which screen edge to sample pixels from</small>
|
||||
<select id="advcal-line-edge" onchange="updateCalibrationLine()">
|
||||
<option value="top">Top</option>
|
||||
<option value="right">Right</option>
|
||||
<option value="bottom">Bottom</option>
|
||||
<option value="left">Left</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="advcal-line-leds" data-i18n="calibration.advanced.led_count">LEDs:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.led_count.hint">Number of LEDs mapped to this line</small>
|
||||
<input type="number" id="advcal-line-leds" min="1" max="1500" value="10" onchange="updateCalibrationLine()">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 12px; margin-top: 8px;">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="advcal-line-span-start" data-i18n="calibration.advanced.span_start">Span Start:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.span_start.hint">Where sampling begins along the edge (0 = start, 1 = end). Use to cover only part of an edge.</small>
|
||||
<input type="number" id="advcal-line-span-start" min="0" max="1" step="0.01" value="0" onchange="updateCalibrationLine()">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="advcal-line-span-end" data-i18n="calibration.advanced.span_end">Span End:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.span_end.hint">Where sampling ends along the edge (0 = start, 1 = end). Together with Span Start, defines the active portion.</small>
|
||||
<input type="number" id="advcal-line-span-end" min="0" max="1" step="0.01" value="1" onchange="updateCalibrationLine()">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="advcal-line-border-width" data-i18n="calibration.advanced.border_width">Depth (px):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.border_width.hint">How many pixels deep from the edge to sample. Larger values capture more of the screen interior.</small>
|
||||
<input type="number" id="advcal-line-border-width" min="1" max="100" value="10" onchange="updateCalibrationLine()">
|
||||
</div>
|
||||
<div class="form-group" style="display:flex; align-items:end; gap:8px;">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="advcal-line-reverse" onchange="updateCalibrationLine()">
|
||||
<span data-i18n="calibration.advanced.reverse">Reverse</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global strip settings (offset, skip) -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-top: 12px;">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="advcal-offset" data-i18n="calibration.offset">LED Offset:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.offset.hint">Distance from physical LED 0 to the start corner (along strip direction)</small>
|
||||
<input type="number" id="advcal-offset" min="0" value="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="advcal-skip-start" data-i18n="calibration.skip_start">Skip LEDs (Start):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.skip_start.hint">Number of LEDs to turn off at the beginning of the strip (0 = none)</small>
|
||||
<input type="number" id="advcal-skip-start" min="0" value="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="advcal-skip-end" data-i18n="calibration.skip_end">Skip LEDs (End):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.skip_end.hint">Number of LEDs to turn off at the end of the strip (0 = none)</small>
|
||||
<input type="number" id="advcal-skip-end" min="0" value="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="advcal-error" class="error-message" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeAdvancedCalibration()" title="Cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveAdvancedCalibration()" title="Save" data-i18n-aria-label="aria.save">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,18 +56,11 @@
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="automations.scene">Scene:</label>
|
||||
<label for="automation-scene-id" data-i18n="automations.scene">Scene:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="automations.scene.hint">Scene preset to activate when conditions are met</small>
|
||||
<div id="automation-scene-selector" class="scene-selector">
|
||||
<input type="hidden" id="automation-scene-id">
|
||||
<div class="scene-selector-input-wrap">
|
||||
<input type="text" id="automation-scene-search" class="scene-selector-input" placeholder="Search scenes..." autocomplete="off" data-i18n-placeholder="automations.scene.search_placeholder">
|
||||
<button type="button" class="scene-selector-clear" id="automation-scene-clear" title="Clear">×</button>
|
||||
</div>
|
||||
<div class="scene-selector-dropdown" id="automation-scene-dropdown"></div>
|
||||
</div>
|
||||
<select id="automation-scene-id"></select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -85,18 +78,11 @@
|
||||
|
||||
<div class="form-group" id="automation-fallback-scene-group" style="display:none">
|
||||
<div class="label-row">
|
||||
<label data-i18n="automations.deactivation_scene">Fallback Scene:</label>
|
||||
<label for="automation-fallback-scene-id" data-i18n="automations.deactivation_scene">Fallback Scene:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="automations.deactivation_scene.hint">Scene to activate when this automation deactivates</small>
|
||||
<div id="automation-fallback-scene-selector" class="scene-selector">
|
||||
<input type="hidden" id="automation-fallback-scene-id">
|
||||
<div class="scene-selector-input-wrap">
|
||||
<input type="text" id="automation-fallback-scene-search" class="scene-selector-input" placeholder="Search scenes..." autocomplete="off" data-i18n-placeholder="automations.scene.search_placeholder">
|
||||
<button type="button" class="scene-selector-clear" id="automation-fallback-scene-clear" title="Clear">×</button>
|
||||
</div>
|
||||
<div class="scene-selector-dropdown" id="automation-fallback-scene-dropdown"></div>
|
||||
</div>
|
||||
<select id="automation-fallback-scene-id"></select>
|
||||
</div>
|
||||
|
||||
<div id="automation-editor-error" class="error-message" style="display: none;"></div>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.type.hint">Picture Source derives colors from a screen capture. Static Color fills all LEDs with one constant color. Gradient distributes a color gradient across all LEDs.</small>
|
||||
<select id="css-editor-type" onchange="onCSSTypeChange()">
|
||||
<option value="picture" data-i18n="color_strip.type.picture">Picture Source</option>
|
||||
<option value="picture_advanced" data-i18n="color_strip.type.picture_advanced">Multi-Monitor</option>
|
||||
<option value="static" data-i18n="color_strip.type.static">Static Color</option>
|
||||
<option value="gradient" data-i18n="color_strip.type.gradient">Gradient</option>
|
||||
<option value="color_cycle" data-i18n="color_strip.type.color_cycle">Color Cycle</option>
|
||||
@@ -36,7 +37,7 @@
|
||||
|
||||
<!-- Picture-source-specific fields -->
|
||||
<div id="css-editor-picture-section">
|
||||
<div class="form-group">
|
||||
<div id="css-editor-picture-source-group" class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-picture-source" data-i18n="color_strip.picture_source">Picture Source:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
@@ -211,7 +212,6 @@
|
||||
<option value="aurora" data-i18n="color_strip.effect.aurora">Aurora</option>
|
||||
</select>
|
||||
<small id="css-editor-effect-type-desc" class="field-desc"></small>
|
||||
<div id="css-editor-effect-preview" class="effect-palette-preview"></div>
|
||||
</div>
|
||||
|
||||
<div id="css-editor-effect-palette-group" class="form-group">
|
||||
@@ -220,7 +220,7 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.palette.hint">Color palette used by the effect.</small>
|
||||
<select id="css-editor-effect-palette" onchange="updateEffectPreview()">
|
||||
<select id="css-editor-effect-palette">
|
||||
<option value="fire" data-i18n="color_strip.palette.fire">Fire</option>
|
||||
<option value="ocean" data-i18n="color_strip.palette.ocean">Ocean</option>
|
||||
<option value="lava" data-i18n="color_strip.palette.lava">Lava</option>
|
||||
|
||||
@@ -33,11 +33,8 @@
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="scenes.targets.hint">Select which targets to include in this scene snapshot</small>
|
||||
<div class="scene-target-add-row">
|
||||
<select id="scene-target-select"></select>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="addSceneTarget()">+</button>
|
||||
</div>
|
||||
<div id="scene-target-list" class="scene-target-list"></div>
|
||||
<button type="button" id="scene-target-add-btn" class="btn btn-sm btn-secondary" onclick="addSceneTarget()" style="margin-top: 6px;">+ <span data-i18n="scenes.targets.add">Add Target</span></button>
|
||||
</div>
|
||||
|
||||
<div id="scene-preset-editor-error" class="error-message" style="display: none;"></div>
|
||||
|
||||
Reference in New Issue
Block a user