feat: daylight tz, camera engine, value stream + modal/UI polish

- daylight: new daylight_settings module + daylight-tz frontend helper; expanded daylight_stream behavior
- camera engine: capture path additions plus new test_camera_engine suite
- value stream: schema + processing updates (~178 lines)
- color strip: drop cycle effect (cycle.py / color-cycle.ts removed), tighten static path
- modal CSS: large refactor (+883), components.css polish (+110)
- templates: settings, css-editor, value-source-editor, test-template, display-picker, image-lightbox
- frontend core: state, modal, icons, graph-nodes, app
- frontend features: displays, streams, streams-capture-templates, value-sources, settings, color-strips/cards
- locales: en/ru/zh
- storage: color_strip, picture_source, value_source loaders touched
- preferences/sync_clocks/picture_sources routes; home_assistant + templates schemas
This commit is contained in:
2026-05-01 18:42:43 +03:00
parent 816a27db73
commit fdac26b9d9
64 changed files with 2716 additions and 837 deletions
@@ -4,7 +4,6 @@ from ledgrab.api.schemas.color_strip_sources import (
ApiInputCSSResponse,
AudioCSSResponse,
CandlelightCSSResponse,
ColorCycleCSSResponse,
ColorStop as ColorStopSchema,
ColorStripSourceResponse,
CompositeCSSResponse,
@@ -31,7 +30,6 @@ from ledgrab.storage.color_strip_source import (
ApiInputColorStripSource,
AudioColorStripSource,
CandlelightColorStripSource,
ColorCycleColorStripSource,
CompositeColorStripSource,
DaylightColorStripSource,
EffectColorStripSource,
@@ -121,10 +119,6 @@ _RESPONSE_MAP: dict = {
easing=s.easing,
gradient_id=s.gradient_id,
),
ColorCycleColorStripSource: lambda s, kw: ColorCycleCSSResponse(
**kw,
colors=[list(c) for c in s.colors],
),
EffectColorStripSource: lambda s, kw: EffectCSSResponse(
**kw,
effect_type=s.effect_type,
@@ -31,7 +31,6 @@ router = APIRouter()
_PREVIEW_ALLOWED_TYPES = {
"static",
"gradient",
"color_cycle",
"effect",
"daylight",
"candlelight",
@@ -316,6 +316,7 @@ async def get_ha_status(
name=source.name,
connected=connected,
entity_count=status["entity_count"] if status else 0,
host=source.host or "",
)
)
@@ -12,6 +12,7 @@ from fastapi.responses import Response
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_picture_source_store,
get_output_target_store,
get_pp_template_store,
@@ -37,6 +38,7 @@ from ledgrab.api.schemas.picture_sources import (
)
from ledgrab.core.capture_engines import EngineRegistry
from ledgrab.core.filters import FilterRegistry, ImagePool
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.template_store import TemplateStore
from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
@@ -361,11 +363,12 @@ async def delete_picture_source(
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
target_store: OutputTargetStore = Depends(get_output_target_store),
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete a picture source."""
try:
# Check if any target references this stream
target_names = store.get_targets_referencing(stream_id, target_store)
# Check if any target transitively references this stream via a CSS
target_names = store.get_targets_referencing(stream_id, target_store, css_store)
if target_names:
names = ", ".join(target_names)
raise HTTPException(
@@ -373,6 +376,16 @@ async def delete_picture_source(
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. "
"Please reassign those targets before deleting.",
)
# Block when any CSS still references this picture source, even if no
# target depends on it — deletion would leave the CSS broken.
css_refs = css_store.get_referencing_picture_source(stream_id)
if css_refs:
css_names = ", ".join(css.name for css in css_refs)
raise HTTPException(
status_code=409,
detail=f"Cannot delete picture source: it is used by color strip source(s): "
f"{css_names}. Please reassign or delete those first.",
)
store.delete_stream(stream_id)
fire_entity_event("picture_source", "deleted", stream_id)
except HTTPException:
+58 -1
View File
@@ -1,4 +1,4 @@
"""User preferences routes — dashboard layout + notification settings.
"""User preferences routes — dashboard layout + notification settings + daylight tz.
The dashboard layout schema is owned by the frontend (open registry of
section/cell keys); the backend treats the value as an opaque JSON blob,
@@ -8,15 +8,26 @@ validates it's a dict with a `version` field, and persists it under the
Notification preferences are validated server-side via Pydantic so the
backend can read them when deciding whether to start the background
discovery watcher.
Daylight timezone is a single global IANA tz name shared by every
daylight value-source / color-strip-source. Stored as
``{"value": "Europe/Berlin"}`` under the ``daylight_timezone`` key, with
empty/missing meaning "use system local time".
"""
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel, Field
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_database
from ledgrab.api.schemas.preferences import NotificationPreferences
from ledgrab.core.processing.daylight_settings import (
DAYLIGHT_TIMEZONE_KEY,
get_daylight_timezone,
set_daylight_timezone,
)
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
@@ -28,6 +39,12 @@ _DASHBOARD_LAYOUT_KEY = "dashboard_layout"
_NOTIFICATION_PREFS_KEY = "notification_preferences"
class DaylightTimezonePreference(BaseModel):
"""Global IANA timezone applied to every daylight cycle source."""
timezone: str = Field("", description="IANA timezone name; empty = system local")
def load_notification_preferences(db: Database | None = None) -> NotificationPreferences:
"""Read notification prefs, returning defaults when unset or corrupt.
@@ -144,3 +161,43 @@ async def put_notification_preferences(
body.channels.model_dump(),
)
return body
# ---------------------------------------------------------------------------
# Daylight timezone (global)
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/preferences/daylight-timezone",
response_model=DaylightTimezonePreference,
tags=["Preferences"],
)
async def get_daylight_timezone_preference(
_: AuthRequired,
) -> DaylightTimezonePreference:
"""Return the global daylight cycle timezone (empty = system local)."""
return DaylightTimezonePreference(timezone=get_daylight_timezone())
@router.put(
"/api/v1/preferences/daylight-timezone",
response_model=DaylightTimezonePreference,
tags=["Preferences"],
)
async def put_daylight_timezone_preference(
_: AuthRequired,
body: DaylightTimezonePreference,
) -> DaylightTimezonePreference:
"""Persist the global daylight cycle timezone.
The string is stored verbatim — clients should send a valid IANA name
(e.g. ``Europe/Berlin``) or an empty string for "use server local".
Daylight streams pick up the new value within ~1 second.
"""
saved = set_daylight_timezone(body.timezone)
logger.info("Daylight timezone updated: %r", saved or "<system local>")
return DaylightTimezonePreference(timezone=saved)
__all__ = ["router", "DAYLIGHT_TIMEZONE_KEY"]
+7 -1
View File
@@ -8,6 +8,7 @@ from ledgrab.api.dependencies import (
get_color_strip_store,
get_sync_clock_manager,
get_sync_clock_store,
get_value_source_store,
)
from ledgrab.api.schemas.sync_clocks import (
SyncClockCreate,
@@ -18,6 +19,7 @@ from ledgrab.api.schemas.sync_clocks import (
from ledgrab.storage.sync_clock import SyncClock
from ledgrab.storage.sync_clock_store import SyncClockStore
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
@@ -137,14 +139,18 @@ async def delete_sync_clock(
_auth: AuthRequired,
store: SyncClockStore = Depends(get_sync_clock_store),
css_store: ColorStripStore = Depends(get_color_strip_store),
vs_store: ValueSourceStore = Depends(get_value_source_store),
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Delete a synchronization clock (fails if referenced by CSS sources)."""
"""Delete a synchronization clock (fails if referenced by CSS or value sources)."""
try:
# Check references
for source in css_store.get_all_sources():
if getattr(source, "clock_id", None) == clock_id:
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
for vs in vs_store.get_all_sources():
if getattr(vs, "clock_id", None) == clock_id:
raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'")
manager.release_all_for(clock_id)
store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", clock_id)
@@ -255,6 +255,7 @@ async def list_engines(_auth: AuthRequired):
type=engine_type,
name=engine_type.upper(),
default_config=engine_class.get_default_config(),
config_choices=engine_class.get_config_choices(),
available=(engine_type in available_set),
has_own_displays=getattr(engine_class, "HAS_OWN_DISPLAYS", False),
)
@@ -105,6 +105,7 @@ _RESPONSE_MAP = {
speed=s.speed,
use_real_time=s.use_real_time,
latitude=s.latitude,
longitude=s.longitude,
min_value=s.min_value,
max_value=s.max_value,
),
@@ -127,6 +128,7 @@ _RESPONSE_MAP = {
colors=[list(c) for c in s.colors],
speed=s.speed,
easing=s.easing,
clock_id=s.clock_id,
),
AdaptiveTimeColorValueSource: lambda s: AdaptiveTimeColorValueSourceResponse(
id=s.id,
@@ -28,7 +28,7 @@ class AnimationConfig(BaseModel):
"""Procedural animation configuration for static/gradient color strip sources."""
enabled: bool = True
type: str = "breathing" # breathing | color_cycle | gradient_shift | wave
type: str = "breathing" # breathing | gradient_shift | wave
speed: float = Field(1.0, ge=0.1, le=10.0, description="Speed multiplier (0.1-10.0)")
@@ -126,11 +126,6 @@ class GradientCSSResponse(_CSSResponseBase):
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
class ColorCycleCSSResponse(_CSSResponseBase):
source_type: Literal["color_cycle"] = "color_cycle"
colors: List[List[int]] = Field(description="List of [R,G,B] colors to cycle")
class EffectCSSResponse(_CSSResponseBase):
source_type: Literal["effect"] = "effect"
effect_type: str = Field(description="Effect algorithm")
@@ -241,7 +236,6 @@ ColorStripSourceResponse = Annotated[
Annotated[PictureAdvancedCSSResponse, Tag("picture_advanced")],
Annotated[StaticCSSResponse, Tag("static")],
Annotated[GradientCSSResponse, Tag("gradient")],
Annotated[ColorCycleCSSResponse, Tag("color_cycle")],
Annotated[EffectCSSResponse, Tag("effect")],
Annotated[CompositeCSSResponse, Tag("composite")],
Annotated[MappedCSSResponse, Tag("mapped")],
@@ -303,11 +297,6 @@ class GradientCSSCreate(_CSSCreateBase):
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
class ColorCycleCSSCreate(_CSSCreateBase):
source_type: Literal["color_cycle"] = "color_cycle"
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle")
class EffectCSSCreate(_CSSCreateBase):
source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm")
@@ -431,7 +420,6 @@ ColorStripSourceCreate = Annotated[
Annotated[PictureAdvancedCSSCreate, Tag("picture_advanced")],
Annotated[StaticCSSCreate, Tag("static")],
Annotated[GradientCSSCreate, Tag("gradient")],
Annotated[ColorCycleCSSCreate, Tag("color_cycle")],
Annotated[EffectCSSCreate, Tag("effect")],
Annotated[CompositeCSSCreate, Tag("composite")],
Annotated[MappedCSSCreate, Tag("mapped")],
@@ -493,11 +481,6 @@ class GradientCSSUpdate(_CSSUpdateBase):
gradient_id: Optional[str] = Field(None, description="Gradient entity ID")
class ColorCycleCSSUpdate(_CSSUpdateBase):
source_type: Literal["color_cycle"] = "color_cycle"
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle")
class EffectCSSUpdate(_CSSUpdateBase):
source_type: Literal["effect"] = "effect"
effect_type: Optional[str] = Field(None, description="Effect algorithm")
@@ -619,7 +602,6 @@ ColorStripSourceUpdate = Annotated[
Annotated[PictureAdvancedCSSUpdate, Tag("picture_advanced")],
Annotated[StaticCSSUpdate, Tag("static")],
Annotated[GradientCSSUpdate, Tag("gradient")],
Annotated[ColorCycleCSSUpdate, Tag("color_cycle")],
Annotated[EffectCSSUpdate, Tag("effect")],
Annotated[CompositeCSSUpdate, Tag("composite")],
Annotated[MappedCSSUpdate, Tag("mapped")],
@@ -94,6 +94,7 @@ class HomeAssistantConnectionStatus(BaseModel):
name: str
connected: bool
entity_count: int
host: str = ""
class HomeAssistantStatusResponse(BaseModel):
@@ -52,6 +52,10 @@ class EngineInfo(BaseModel):
type: str = Field(description="Engine type identifier (e.g., 'mss', 'dxcam')")
name: str = Field(description="Human-readable engine name")
default_config: Dict = Field(description="Default configuration for this engine")
config_choices: Dict[str, List[str]] = Field(
default_factory=dict,
description="Allowed values for enum-like config keys on this platform",
)
available: bool = Field(description="Whether engine is available on this system")
has_own_displays: bool = Field(
default=False, description="Engine has its own device list (not desktop monitors)"
@@ -73,6 +73,7 @@ class DaylightValueSourceResponse(_ValueSourceResponseBase):
speed: float = Field(description="Simulation speed multiplier")
use_real_time: bool = Field(description="Use wall-clock time")
latitude: float = Field(description="Geographic latitude")
longitude: float = Field(description="Geographic longitude")
min_value: float = Field(description="Minimum output")
max_value: float = Field(description="Maximum output")
@@ -87,8 +88,11 @@ class AnimatedColorValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["animated_color"] = "animated_color"
return_type: Literal["color"] = "color"
colors: List[List[int]] = Field(description="Color list [[R,G,B], ...]")
speed: float = Field(description="Cycles per minute")
easing: str = Field(description="Color easing: linear|step")
speed: float = Field(description="Cycles per minute (ignored when clock_id is set)")
easing: str = Field(description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine")
clock_id: Optional[str] = Field(
None, description="Optional sync clock ID for shared timing (overrides speed)"
)
class AdaptiveTimeColorValueSourceResponse(_ValueSourceResponseBase):
@@ -215,6 +219,9 @@ class DaylightValueSourceCreate(_ValueSourceCreateBase):
speed: float = Field(1.0, description="Simulation speed multiplier", ge=0.1, le=120.0)
use_real_time: bool = Field(False, description="Use wall-clock time instead of simulation")
latitude: float = Field(50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: float = Field(
0.0, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0
)
min_value: float = Field(0.0, description="Minimum output (0.0-1.0)", ge=0.0, le=1.0)
max_value: float = Field(1.0, description="Maximum output (0.0-1.0)", ge=0.0, le=1.0)
@@ -234,7 +241,12 @@ class AnimatedColorValueSourceCreate(_ValueSourceCreateBase):
description="Color list [[R,G,B], ...]",
)
speed: float = Field(10.0, description="Cycles per minute", ge=0.1, le=120.0)
easing: str = Field("linear", description="Color easing: linear|step")
easing: str = Field(
"linear", description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
)
clock_id: Optional[str] = Field(
None, description="Optional sync clock ID (overrides speed when set)"
)
class AdaptiveTimeColorValueSourceCreate(_ValueSourceCreateBase):
@@ -356,6 +368,9 @@ class DaylightValueSourceUpdate(_ValueSourceUpdateBase):
speed: Optional[float] = Field(None, description="Simulation speed", ge=0.1, le=120.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time")
latitude: Optional[float] = Field(None, description="Geographic latitude", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(
None, description="Geographic longitude", ge=-180.0, le=180.0
)
min_value: Optional[float] = Field(None, description="Minimum output", ge=0.0, le=1.0)
max_value: Optional[float] = Field(None, description="Maximum output", ge=0.0, le=1.0)
@@ -369,7 +384,12 @@ class AnimatedColorValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["animated_color"] = "animated_color"
colors: Optional[List[List[int]]] = Field(None, description="Color list [[R,G,B], ...]")
speed: Optional[float] = Field(None, description="Cycles per minute", ge=0.1, le=120.0)
easing: Optional[str] = Field(None, description="Color easing: linear|step")
easing: Optional[str] = Field(
None, description="Color easing: linear|step|ease_in|ease_out|ease_in_out|sine"
)
clock_id: Optional[str] = Field(
None, description="Optional sync clock ID (empty string clears, null leaves unchanged)"
)
class AdaptiveTimeColorValueSourceUpdate(_ValueSourceUpdateBase):
@@ -117,6 +117,16 @@ class CaptureEngine(ABC):
"""Get default configuration for this engine."""
pass
@classmethod
def get_config_choices(cls) -> Dict[str, List[str]]:
"""Return allowed values for enum-like config keys on this platform.
Keys returned here narrow the values the UI offers for the
corresponding config field. Engines that have no platform-specific
constraints can leave this empty (default).
"""
return {}
@classmethod
@abstractmethod
def get_available_displays(cls) -> List[DisplayInfo]:
@@ -8,12 +8,19 @@ Prerequisites (optional dependency):
pip install opencv-python-headless>=4.8.0
"""
import os
import platform
import sys
import threading
import time
from typing import Any, Dict, List, Optional, Set
# OpenCV's MSMF backend on Windows often fails to open the device
# ("cap.isOpened() == False" right after VideoCapture returns) when
# hardware MFTs are enabled. Disabling them is the documented mitigation.
# Set before any cv2 import so the MSMF backend picks it up on first use.
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
from ledgrab.core.capture_engines.base import (
CaptureEngine,
@@ -27,6 +34,41 @@ logger = get_logger(__name__)
_MAX_CAMERA_INDEX = 10 # probe indices 0..9
# Sentinel used to ask DShow/MSMF/V4L2 for the highest mode the device supports.
# OpenCV will clamp the requested width/height down to the nearest supported mode.
_PROBE_MAX_DIM = 9999
# Resolution presets shown in the UI. "auto" means: open at the camera's max
# (probed via _PROBE_MAX_DIM); the other entries are explicit overrides.
_RESOLUTION_CHOICES: List[str] = [
"auto",
"640x480",
"1280x720",
"1920x1080",
"2560x1440",
"3840x2160",
]
def _parse_resolution(value: Any) -> Optional[tuple[int, int]]:
"""Parse a 'WxH' string into (width, height). Returns None for 'auto' or invalid."""
if not isinstance(value, str):
return None
s = value.strip().lower()
if s in ("", "auto"):
return None
parts = s.replace("×", "x").split("x")
if len(parts) != 2:
return None
try:
w, h = int(parts[0]), int(parts[1])
except ValueError:
return None
if w <= 0 or h <= 0:
return None
return w, h
# Process-wide registry of cv2 camera indices currently held open.
# Prevents _enumerate_cameras from probing an in-use camera (which can
# crash the DSHOW backend on Windows) and prevents two CameraCaptureStreams
@@ -48,6 +90,85 @@ def _get_default_backend():
return "auto"
# Maps our backend ids to the label cv2.getBuildInformation() prints in the
# Video I/O section. Entries missing or marked "NO" mean the installed
# opencv wheel was compiled without that backend — even if cv2's registry
# still lists it, attempts to open will fail with isOpened()==False.
_BUILDINFO_LABELS: Dict[str, str] = {
"dshow": "DirectShow",
"msmf": "Media Foundation",
"v4l2": "v4l/v4l2",
"avfoundation": "AVFoundation",
}
_compiled_backends_cache: Optional[Set[str]] = None
def _get_compiled_backends() -> Set[str]:
"""Return the set of backend ids the installed cv2 was compiled with.
Parses ``cv2.getBuildInformation()`` because cv2's videoio registry can
advertise backends that aren't actually functional (e.g. wheels that
omit Media Foundation still list MSMF in the registry).
"""
global _compiled_backends_cache
if _compiled_backends_cache is not None:
return _compiled_backends_cache
try:
import cv2
except ImportError:
_compiled_backends_cache = set()
return _compiled_backends_cache
info = cv2.getBuildInformation()
# Restrict the search to the "Video I/O" section so labels like
# "Media Foundation" don't pick up unrelated mentions elsewhere.
start = info.find("Video I/O:")
section = info[start:] if start != -1 else info
end_markers = ("Parallel framework", "Trace:", "Other third-party libraries")
for marker in end_markers:
idx = section.find(marker)
if idx != -1:
section = section[:idx]
break
found: Set[str] = set()
for backend, label in _BUILDINFO_LABELS.items():
# Match "<label>: <whitespace>YES" anywhere in the section.
needle = label + ":"
pos = section.find(needle)
if pos == -1:
continue
line_end = section.find("\n", pos)
line = section[pos : line_end if line_end != -1 else len(section)]
if "YES" in line.upper():
found.add(backend)
_compiled_backends_cache = found
return found
def _get_supported_backends() -> List[str]:
"""Return the list of cv2 backends that make sense on this platform.
Only advertises backends that are both (a) appropriate for the host OS
and (b) actually compiled into the installed opencv wheel. ``auto`` is
always offered as a safe default.
"""
if sys.platform == "win32":
candidates = ["dshow", "msmf"]
elif sys.platform.startswith("linux"):
candidates = ["v4l2"]
elif sys.platform == "darwin":
candidates = ["avfoundation"]
else:
candidates = []
compiled = _get_compiled_backends()
return ["auto", *(b for b in candidates if b in compiled)]
def _cv2_backend_id(backend_name: str) -> Optional[int]:
"""Convert a backend name string to cv2 API preference constant."""
return _CV2_BACKENDS.get(backend_name)
@@ -256,8 +377,20 @@ def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]:
cap.release()
continue
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
# Probe the camera's max supported mode by asking for an absurdly large
# frame size — DShow/MSMF/V4L2 clamp down to the highest available mode.
# If the probe is rejected (rare driver issue), fall back to the default
# mode that the camera reports immediately after open.
default_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
default_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
try:
cap.set(cv2.CAP_PROP_FRAME_WIDTH, _PROBE_MAX_DIM)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, _PROBE_MAX_DIM)
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or default_width
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or default_height
except Exception as e:
logger.debug(f"Camera {i} max-resolution probe failed: {e}")
width, height = default_width, default_height
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
name = friendly_names.get(sequential_idx, f"Camera {sequential_idx}")
@@ -328,16 +461,28 @@ class CameraCaptureStream(CaptureStream):
_active_cv2_indices.add(cv2_index)
try:
# Open the camera
# Open the camera. MSMF's first open after a DShow session (or its
# very first cold open in the process) is timing-sensitive on
# Windows, so retry briefly before giving up.
backend_id = _cv2_backend_id(backend_name)
if backend_id is not None:
self._cap = cv2.VideoCapture(cv2_index, backend_id)
else:
self._cap = cv2.VideoCapture(cv2_index)
attempts = 3 if backend_name == "msmf" else 1
self._cap = None
for attempt in range(attempts):
if backend_id is not None:
cap = cv2.VideoCapture(cv2_index, backend_id)
else:
cap = cv2.VideoCapture(cv2_index)
if cap.isOpened():
self._cap = cap
break
cap.release()
if attempt + 1 < attempts:
time.sleep(0.5)
if not self._cap.isOpened():
if self._cap is None or not self._cap.isOpened():
raise RuntimeError(
f"Failed to open camera {self.display_index} " f"(cv2 index {cv2_index})"
f"Failed to open camera {self.display_index} "
f"(cv2 index {cv2_index}, backend={backend_name})"
)
except Exception:
with _camera_lock:
@@ -346,12 +491,28 @@ class CameraCaptureStream(CaptureStream):
self._cv2_index = cv2_index
# Apply optional resolution override
res_w = self.config.get("resolution_width", 0)
res_h = self.config.get("resolution_height", 0)
if res_w > 0 and res_h > 0:
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, res_w)
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, res_h)
# Resolve effective resolution.
# Priority: legacy `resolution_width`/`resolution_height` (if both > 0)
# → new `resolution` enum string (e.g. "1920x1080" or "auto")
# → "auto" (open at the camera's max).
# On Windows DShow/MSMF the default opening mode is typically 640x480
# regardless of the camera's hardware ceiling, so when no explicit
# override is given we ask for the highest mode the device supports
# by setting an absurdly large frame size — drivers clamp down to the
# nearest supported mode.
legacy_w = self.config.get("resolution_width", 0) or 0
legacy_h = self.config.get("resolution_height", 0) or 0
if legacy_w > 0 and legacy_h > 0:
target_w, target_h = legacy_w, legacy_h
else:
parsed = _parse_resolution(self.config.get("resolution", "auto"))
if parsed is not None:
target_w, target_h = parsed
else:
target_w, target_h = _PROBE_MAX_DIM, _PROBE_MAX_DIM
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, target_w)
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, target_h)
# Test read
ret, frame = self._cap.read()
@@ -434,10 +595,20 @@ class CameraEngine(CaptureEngine):
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
# `resolution` is the user-facing control. Legacy numeric overrides
# `resolution_width`/`resolution_height` are still honored if present
# in stored configs (see CameraCaptureStream.initialize), but are no
# longer surfaced in the default config — the dropdown replaces them.
return {
"camera_backend": _get_default_backend(),
"resolution_width": 0,
"resolution_height": 0,
"resolution": "auto",
}
@classmethod
def get_config_choices(cls) -> Dict[str, List[str]]:
return {
"camera_backend": _get_supported_backends(),
"resolution": list(_RESOLUTION_CHOICES),
}
@classmethod
-17
View File
@@ -32,7 +32,6 @@ _PS_IDS = {
_CSS_IDS = {
"gradient": "css_demo0001",
"cycle": "css_demo0002",
"picture": "css_demo0003",
"audio": "css_demo0004",
}
@@ -267,22 +266,6 @@ def _build_color_strip_sources() -> dict:
"created_at": _NOW,
"updated_at": _NOW,
},
_CSS_IDS["cycle"]: {
"id": _CSS_IDS["cycle"],
"name": "Warm Color Cycle",
"source_type": "color_cycle",
"description": "Smoothly cycles through warm colors",
"clock_id": None,
"tags": ["demo"],
"colors": [
[255, 60, 0],
[255, 140, 0],
[255, 200, 50],
[255, 100, 20],
],
"created_at": _NOW,
"updated_at": _NOW,
},
_CSS_IDS["picture"]: {
"id": _CSS_IDS["picture"],
"name": "Screen Capture — Main Display",
@@ -6,7 +6,6 @@ via the shim module ``color_strip_stream.py``.
"""
from .base import ColorStripStream, _SimpleNoise1D, _gradient_noise
from .cycle import ColorCycleColorStripStream
from .gradient import GradientColorStripStream
from .helpers import _compute_gradient_colors
from .picture import PictureColorStripStream
@@ -16,7 +15,6 @@ __all__ = [
"ColorStripStream",
"PictureColorStripStream",
"StaticColorStripStream",
"ColorCycleColorStripStream",
"GradientColorStripStream",
"_compute_gradient_colors",
"_SimpleNoise1D",
@@ -1,185 +0,0 @@
"""Color cycle stream — smoothly cycles through user-defined colors."""
import threading
import time
from typing import Optional
import numpy as np
from ledgrab.utils import get_logger
from ledgrab.utils.frame_limiter import FrameLimiter
from ledgrab.utils.timer import high_resolution_timer
from .base import ColorStripStream
logger = get_logger(__name__)
class ColorCycleColorStripStream(ColorStripStream):
"""Color strip stream that smoothly cycles through a user-defined color list.
All LEDs receive the same solid color at any moment, continuously interpolating
between the configured colors in a loop.
LED count auto-sizes from the connected device when led_count == 0 in
the source config; configure(device_led_count) is called by
WledTargetProcessor on start.
"""
def __init__(self, source):
self._colors_lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._frame_time = 1.0 / 30
self._clock = None # optional SyncClockRuntime
self._update_from_source(source)
def _update_from_source(self, source) -> None:
raw = source.colors if isinstance(source.colors, list) else []
default = [
[255, 0, 0],
[255, 255, 0],
[0, 255, 0],
[0, 255, 255],
[0, 0, 255],
[255, 0, 255],
]
self._color_list = [c for c in raw if isinstance(c, list) and len(c) == 3] or default
_lc = getattr(source, "led_count", 0)
self._auto_size = not _lc
self._led_count = _lc if _lc > 0 else 1
self._rebuild_colors()
def _rebuild_colors(self) -> None:
pixel = np.array(self._color_list[0], dtype=np.uint8)
colors = np.tile(pixel, (self._led_count, 1))
with self._colors_lock:
self._colors = colors
def configure(self, device_led_count: int) -> None:
"""Size to device LED count when led_count was 0 (auto-size)."""
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
self._led_count = device_led_count
self._rebuild_colors()
logger.debug(f"ColorCycleColorStripStream auto-sized to {device_led_count} LEDs")
@property
def target_fps(self) -> int:
return self._fps
@property
def led_count(self) -> int:
return self._led_count
def set_capture_fps(self, fps: int) -> None:
"""Update animation loop rate. Thread-safe (read atomically by the loop)."""
fps = max(1, min(90, fps))
self._fps = fps
self._frame_time = 1.0 / fps
def start(self) -> None:
if self._running:
return
self._running = True
self._thread = threading.Thread(
target=self._animate_loop,
name="css-color-cycle",
daemon=True,
)
self._thread.start()
logger.info(
f"ColorCycleColorStripStream started (leds={self._led_count}, colors={len(self._color_list)})"
)
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=5.0)
if self._thread.is_alive():
logger.warning(
"ColorCycleColorStripStream animate thread did not terminate within 5s"
)
self._thread = None
logger.info("ColorCycleColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._colors
def update_source(self, source) -> None:
from ledgrab.storage.color_strip_source import ColorCycleColorStripSource
if isinstance(source, ColorCycleColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
self._rebuild_colors()
logger.info("ColorCycleColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
self._clock = clock
def _animate_loop(self) -> None:
"""Background thread: interpolate between colors at target fps.
Uses double-buffered output arrays to avoid per-frame allocations.
"""
_pool_n = 0
_buf_a = _buf_b = None
_use_a = True
limiter = FrameLimiter(self._fps)
try:
with high_resolution_timer():
while self._running:
limiter.begin()
wall_start = time.perf_counter()
frame_time = self._frame_time
try:
color_list = self._color_list
clock = self._clock
if clock:
if not clock.is_running:
time.sleep(0.1)
continue
speed = clock.speed
t = clock.get_time()
else:
speed = 1.0
t = wall_start
n = self._led_count
num = len(color_list)
if num >= 2:
if n != _pool_n:
_pool_n = n
_buf_a = np.empty((n, 3), dtype=np.uint8)
_buf_b = np.empty((n, 3), dtype=np.uint8)
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
# 0.05 factor → one full cycle every 20s at speed=1.0
cycle_pos = (speed * t * 0.05) % 1.0
seg = cycle_pos * num
idx = int(seg) % num
t_i = seg - int(seg)
c1 = color_list[idx]
c2 = color_list[(idx + 1) % num]
buf[:] = (
min(255, int(c1[0] + (c2[0] - c1[0]) * t_i)),
min(255, int(c1[1] + (c2[1] - c1[1]) * t_i)),
min(255, int(c1[2] + (c2[2] - c1[2]) * t_i)),
)
with self._colors_lock:
self._colors = buf
except Exception as e:
logger.error(f"ColorCycleColorStripStream animation error: {e}")
limiter.wait(frame_time)
except Exception as e:
logger.error(f"Fatal ColorCycleColorStripStream loop error: {e}", exc_info=True)
finally:
self._running = False
@@ -73,7 +73,14 @@ class StaticColorStripStream(ColorStripStream):
@property
def is_animated(self) -> bool:
anim = self._animation
return bool(anim and anim.get("enabled"))
if anim and anim.get("enabled"):
return True
return self._is_color_bound()
def _is_color_bound(self) -> bool:
"""True when the `color` property is driven by a ValueStream."""
vs = self._value_streams
return bool(vs and vs.get("color"))
@property
def led_count(self) -> int:
@@ -243,10 +250,28 @@ class StaticColorStripStream(ColorStripStream):
if colors is not None:
with self._colors_lock:
self._colors = colors
elif self._is_color_bound():
# No animation, but color is driven by a ValueStream —
# poll and forward live color updates so the bound
# source is honoured (otherwise LEDs stay stuck on
# the static fallback).
n = self._led_count
if n != _pool_n:
_pool_n = n
_buf_a = np.empty((n, 3), dtype=np.uint8)
_buf_b = np.empty((n, 3), dtype=np.uint8)
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
buf[:] = self.resolve_color("color", self._source_color)
with self._colors_lock:
self._colors = buf
except Exception as e:
logger.error(f"StaticColorStripStream animation error: {e}")
sleep_target = frame_time if anim and anim.get("enabled") else 0.25
if (anim and anim.get("enabled")) or self._is_color_bound():
sleep_target = frame_time
else:
sleep_target = 0.25
limiter.wait(sleep_target)
except Exception as e:
logger.error(f"Fatal StaticColorStripStream loop error: {e}", exc_info=True)
@@ -6,7 +6,6 @@ import path ``from ledgrab.core.processing.color_strip_stream import X``.
"""
from ledgrab.core.processing.color_strip import ( # noqa: F401
ColorCycleColorStripStream,
ColorStripStream,
GradientColorStripStream,
PictureColorStripStream,
@@ -20,7 +19,6 @@ __all__ = [
"ColorStripStream",
"PictureColorStripStream",
"StaticColorStripStream",
"ColorCycleColorStripStream",
"GradientColorStripStream",
"_compute_gradient_colors",
"_SimpleNoise1D",
@@ -3,7 +3,7 @@
PictureColorStripStreams (expensive screen capture) are shared across multiple
consumers via reference counting — processing runs once, not once per target.
Count-dependent streams (static, gradient, color cycle, effect) are NOT shared.
Count-dependent streams (static, gradient, effect) are NOT shared.
Each consumer gets its own instance so it can configure an independent LED count
without interfering with other targets.
"""
@@ -12,7 +12,6 @@ from dataclasses import dataclass
from typing import Dict, Optional
from ledgrab.core.processing.color_strip_stream import (
ColorCycleColorStripStream,
ColorStripStream,
GradientColorStripStream,
PictureColorStripStream,
@@ -34,7 +33,6 @@ logger = get_logger(__name__)
_SIMPLE_STREAM_MAP = {
"static": StaticColorStripStream,
"gradient": GradientColorStripStream,
"color_cycle": ColorCycleColorStripStream,
"effect": EffectColorStripStream,
"api_input": ApiInputColorStripStream,
"notification": NotificationColorStripStream,
@@ -0,0 +1,87 @@
"""Global daylight cycle preferences.
A single timezone applies to every daylight value source / color strip
source on the server, so it lives in the key/value settings table rather
than on each entity. The daylight streams read it on every wall-clock
sample (cheap dict lookup with a short cache window) so changing it in
the UI takes effect within ~1 second.
"""
from __future__ import annotations
import threading
import time
from typing import Optional
from ledgrab.utils import get_logger
logger = get_logger(__name__)
DAYLIGHT_TIMEZONE_KEY = "daylight_timezone"
_CACHE_TTL_SECONDS = 1.0
_lock = threading.Lock()
_cached_tz: str = ""
_cached_at: float = 0.0
def _read_from_db() -> str:
"""Read the persisted timezone from the settings table.
Returns an empty string when unset, the table is unavailable, or
the stored value is corrupt — empty means "use system local time".
"""
try:
from ledgrab.api.dependencies import get_database
raw = get_database().get_setting(DAYLIGHT_TIMEZONE_KEY)
except Exception as e: # pragma: no cover — DB not initialised yet, e.g. in tests
logger.debug("daylight timezone DB read failed: %s", e)
return ""
if not isinstance(raw, dict):
return ""
value = raw.get("value")
return str(value) if isinstance(value, str) else ""
def get_daylight_timezone() -> str:
"""Return the configured global daylight timezone (cached briefly)."""
global _cached_tz, _cached_at
now = time.monotonic()
with _lock:
if now - _cached_at < _CACHE_TTL_SECONDS:
return _cached_tz
fresh = _read_from_db()
with _lock:
_cached_tz = fresh
_cached_at = now
return fresh
def set_daylight_timezone(tz: Optional[str]) -> str:
"""Persist the global daylight timezone and refresh the cache.
Returns the canonicalised stored value (empty string for None / blank).
"""
canonical = str(tz or "").strip()
try:
from ledgrab.api.dependencies import get_database
get_database().set_setting(DAYLIGHT_TIMEZONE_KEY, {"value": canonical})
except Exception as e:
logger.warning("Failed to persist daylight timezone: %s", e)
global _cached_tz, _cached_at
with _lock:
_cached_tz = canonical
_cached_at = time.monotonic()
return canonical
def invalidate_cache() -> None:
"""Force the next ``get_daylight_timezone`` call to re-read from DB."""
global _cached_at
with _lock:
_cached_at = 0.0
@@ -22,8 +22,33 @@ from ledgrab.utils import get_logger
from ledgrab.utils.frame_limiter import FrameLimiter
from ledgrab.utils.timer import high_resolution_timer
try:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
except ImportError: # pragma: no cover — pre-3.9 fallback, not expected in target envs
ZoneInfo = None # type: ignore[assignment]
class ZoneInfoNotFoundError(Exception): # type: ignore[no-redef]
pass
logger = get_logger(__name__)
def _now_in_tz(tz_name: str) -> datetime.datetime:
"""Return current wall-clock time in the named IANA timezone.
Empty string means "use system local time". Unknown timezones fall
back to local time and log a warning once per unknown name.
"""
if not tz_name or ZoneInfo is None:
return datetime.datetime.now()
try:
return datetime.datetime.now(ZoneInfo(tz_name))
except ZoneInfoNotFoundError:
logger.warning(f"Unknown daylight timezone '{tz_name}' — falling back to system local")
return datetime.datetime.now()
# ── Daylight color table ────────────────────────────────────────────────
#
# Canonical hour control points (024) → RGB. Designed for a default
@@ -62,13 +87,19 @@ _daylight_lut: Optional[np.ndarray] = None
# ── Solar position helpers ──────────────────────────────────────────────
def _compute_solar_times(latitude: float, longitude: float, day_of_year: int) -> tuple:
"""Return (sunrise_hour, sunset_hour) in local solar time.
def _compute_solar_times(
latitude: float,
longitude: float,
day_of_year: int,
utc_offset_hours: float = 0.0,
) -> tuple:
"""Return (sunrise_hour, sunset_hour) in the user's wall-clock time.
Uses simplified NOAA solar equations:
- declination: decl = 23.45 * sin(2π * (284 + doy) / 365)
- hour angle: cos(ha) = -tan(lat) * tan(decl)
- sunrise/sunset: 12 ∓ ha/15, shifted by longitude
- solar noon (UTC): 12 - longitude/15
- wall-clock sunrise/sunset: solar_noon_utc + utc_offset ∓ ha/15
Polar day and polar night are clamped to visible ranges.
"""
@@ -79,28 +110,48 @@ def _compute_solar_times(latitude: float, longitude: float, day_of_year: int) ->
lat_rad = latitude * deg2rad
cos_ha = -math.tan(lat_rad) * math.tan(decl_rad)
solar_noon_utc = 12.0 - longitude / 15.0
solar_noon_local = solar_noon_utc + utc_offset_hours
if cos_ha <= -1.0:
# Polar day — sun never sets
sunrise = 3.0
sunset = 21.0
# Polar day — sun never sets; fake a long visible window
sunrise = solar_noon_local - 9.0
sunset = solar_noon_local + 9.0
elif cos_ha >= 1.0:
# Polar night — sun never rises
sunrise = 12.0
sunset = 12.0
# Polar night — sun never rises; collapse to noon
sunrise = solar_noon_local
sunset = solar_noon_local
else:
ha_hours = math.acos(cos_ha) / (deg2rad * 15.0)
lon_offset = longitude / 15.0
solar_noon = 12.0 - lon_offset
sunrise = solar_noon - ha_hours
sunset = solar_noon + ha_hours
sunrise = solar_noon_local - ha_hours
sunset = solar_noon_local + ha_hours
# Clamp to sane ranges
sunrise = max(3.0, min(10.0, sunrise))
sunset = max(14.0, min(21.0, sunset))
# Clamp to a safe range the LUT builder can render. With reasonable
# tz/longitude pairs sunrise lands in (3..10) and sunset in (14..21);
# we widen the clamp so weird tz/lon combinations still produce a
# usable curve instead of dividing by zero.
sunrise = max(0.5, min(11.5, sunrise))
sunset = max(12.5, min(23.5, sunset))
return sunrise, sunset
def _utc_offset_hours_for(tz_name: str, when: Optional[datetime.datetime] = None) -> float:
"""Return the UTC offset (in hours) for the given IANA timezone.
Empty/unknown tz falls back to the system local offset for ``when``.
"""
when = when or datetime.datetime.now()
if tz_name and ZoneInfo is not None:
try:
offset = when.replace(tzinfo=None).astimezone(ZoneInfo(tz_name)).utcoffset()
if offset is not None:
return offset.total_seconds() / 3600.0
except ZoneInfoNotFoundError:
pass
local_offset = when.astimezone().utcoffset()
return local_offset.total_seconds() / 3600.0 if local_offset else 0.0
def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray:
"""Build a 1440-entry uint8 RGB LUT scaled to the given sunrise/sunset hours.
@@ -198,9 +249,11 @@ class DaylightColorStripStream(ColorStripStream):
with self._colors_lock:
self._colors: Optional[np.ndarray] = None
def _get_lut_for_day(self, day_of_year: int) -> np.ndarray:
def _get_lut_for_day(self, day_of_year: int, utc_offset_hours: float = 0.0) -> np.ndarray:
"""Return a solar-time-aware LUT for the given day (cached)."""
sunrise, sunset = _compute_solar_times(self._latitude, self._longitude, day_of_year)
sunrise, sunset = _compute_solar_times(
self._latitude, self._longitude, day_of_year, utc_offset_hours
)
sr_key = int(round(sunrise * 60))
ss_key = int(round(sunset * 60))
cache_key = (sr_key, ss_key)
@@ -304,10 +357,16 @@ class DaylightColorStripStream(ColorStripStream):
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
from ledgrab.core.processing.daylight_settings import (
get_daylight_timezone,
)
tz_name = get_daylight_timezone()
if self._use_real_time:
now = datetime.datetime.now()
now = _now_in_tz(tz_name)
day_of_year = now.timetuple().tm_yday
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
utc_offset_hours = _utc_offset_hours_for(tz_name, now)
else:
# Simulated: speed=1.0 → full 24h in 240s.
# Use summer solstice (day 172) for maximum day length.
@@ -315,8 +374,9 @@ class DaylightColorStripStream(ColorStripStream):
cycle_seconds = 240.0 / max(speed, 0.01)
phase = (t % cycle_seconds) / cycle_seconds
minute_of_day = phase * 1440.0
utc_offset_hours = _utc_offset_hours_for(tz_name)
lut = self._get_lut_for_day(day_of_year)
lut = self._get_lut_for_day(day_of_year, utc_offset_hours)
idx = int(minute_of_day) % 1440
color = lut[idx]
buf[:] = color
@@ -167,6 +167,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
gradient_store=deps.gradient_store,
event_bus=deps.game_event_bus,
audio_processing_template_store=deps.audio_processing_template_store,
sync_clock_manager=deps.sync_clock_manager,
)
if deps.value_source_store
else None
@@ -37,6 +37,7 @@ if TYPE_CHECKING:
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager
from ledgrab.core.processing.live_stream_manager import LiveStreamManager
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from ledgrab.storage.audio_source_store import AudioSourceStore
from ledgrab.storage.value_source import ValueSource
from ledgrab.storage.value_source_store import ValueSourceStore
@@ -599,31 +600,61 @@ class DaylightValueStream(ValueStream):
speed: float = 1.0,
use_real_time: bool = False,
latitude: float = 50.0,
longitude: float = 0.0,
min_value: float = 0.0,
max_value: float = 1.0,
):
from ledgrab.core.processing.daylight_stream import _get_daylight_lut
self._lut = _get_daylight_lut()
self._default_lut = _get_daylight_lut()
self._speed = speed
self._use_real_time = use_real_time
self._latitude = latitude
self._longitude = longitude
self._min = min_value
self._max = max_value
self._start_time = time.perf_counter()
# Cache: (sr_min, ss_min) → LUT, mirroring DaylightColorStripStream
self._lut_cache: Dict[Tuple[int, int], np.ndarray] = {}
def _resolve_lut(self, day_of_year: Optional[int], utc_offset_hours: float) -> np.ndarray:
if day_of_year is None:
return self._default_lut
from ledgrab.core.processing.daylight_stream import (
_build_lut_for_solar_times,
_compute_solar_times,
)
sr, ss = _compute_solar_times(
self._latitude, self._longitude, day_of_year, utc_offset_hours
)
key = (int(round(sr * 60)), int(round(ss * 60)))
lut = self._lut_cache.get(key)
if lut is None:
lut = _build_lut_for_solar_times(sr, ss)
if len(self._lut_cache) > 8:
self._lut_cache.clear()
self._lut_cache[key] = lut
return lut
def get_value(self) -> float:
from ledgrab.core.processing.daylight_settings import get_daylight_timezone
from ledgrab.core.processing.daylight_stream import _now_in_tz, _utc_offset_hours_for
tz_name = get_daylight_timezone()
if self._use_real_time:
now = datetime.now()
now = _now_in_tz(tz_name)
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
lut = self._resolve_lut(now.timetuple().tm_yday, _utc_offset_hours_for(tz_name, now))
else:
t_elapsed = time.perf_counter() - self._start_time
cycle_seconds = 240.0 / max(self._speed, 0.01)
phase = (t_elapsed % cycle_seconds) / cycle_seconds
minute_of_day = phase * 1440.0
lut = self._default_lut
idx = int(minute_of_day) % 1440
r, g, b = self._lut[idx]
r, g, b = lut[idx]
# BT.601 luminance → 0..1
luminance = (0.299 * float(r) + 0.587 * float(g) + 0.114 * float(b)) / 255.0
@@ -637,8 +668,10 @@ class DaylightValueStream(ValueStream):
self._speed = source.speed
self._use_real_time = source.use_real_time
self._latitude = source.latitude
self._longitude = source.longitude
self._min = source.min_value
self._max = source.max_value
self._lut_cache.clear()
# ---------------------------------------------------------------------------
@@ -669,10 +702,34 @@ class StaticColorValueStream(ValueStream):
)
class AnimatedColorValueStream(ValueStream):
"""Cycles through a list of colors over time."""
def _ease_color_frac(t: float, easing: str) -> float:
"""Remap a 0..1 segment fraction through a named easing curve.
def __init__(self, colors, speed=10.0, easing="linear"):
Unknown names fall back to linear so older configs and forward-compat
payloads keep working.
"""
if easing == "ease_in":
return t * t * t
if easing == "ease_out":
u = 1.0 - t
return 1.0 - u * u * u
if easing == "ease_in_out":
return t * t * (3.0 - 2.0 * t)
if easing == "sine":
return 0.5 - 0.5 * math.cos(math.pi * t)
return t
class AnimatedColorValueStream(ValueStream):
"""Cycles through a list of colors over time.
When a ``clock`` runtime is provided, animation is driven by the
clock's pause-aware elapsed time and speed multiplier so multiple
streams sharing the same clock stay in lockstep. When no clock is
set, falls back to wall-clock time scaled by ``speed`` (cycles/min).
"""
def __init__(self, colors, speed=10.0, easing="linear", clock=None):
self._colors = [
(int(c[0]), int(c[1]), int(c[2]))
for c in (colors or [[255, 0, 0], [0, 255, 0], [0, 0, 255]])
@@ -681,24 +738,47 @@ class AnimatedColorValueStream(ValueStream):
self._speed = max(0.01, float(speed))
self._easing = easing
self._start_time = 0.0
self._clock = clock
# Last frame state — held while the clock is paused so get_color()
# returns a stable color instead of jumping.
self._last_phase = 0.0
def start(self) -> None:
self._start_time = time.monotonic()
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime. Thread-safe (atomic ref swap)."""
self._clock = clock
def get_value(self) -> float:
r, g, b = self.get_color()
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
def get_color(self) -> tuple:
elapsed = time.monotonic() - self._start_time
cycle_time = 60.0 / self._speed
clock = self._clock
n = len(self._colors)
if clock is not None:
# Clock provides real elapsed seconds (pause-aware) and a speed
# multiplier. We treat self._speed as the base cpm and apply the
# clock's speed on top, matching the convention used by CSS
# animation streams.
cycle_time = 60.0 / max(0.01, self._speed * float(clock.speed))
if not clock.is_running:
phase = self._last_phase
else:
elapsed = clock.get_time()
phase = (elapsed / cycle_time * n) % n
self._last_phase = phase
else:
elapsed = time.monotonic() - self._start_time
cycle_time = 60.0 / self._speed
phase = (elapsed / cycle_time * n) % n
self._last_phase = phase
if self._easing == "step":
idx = int((elapsed / cycle_time * n) % n)
return self._colors[idx]
phase = (elapsed / cycle_time * n) % n
return self._colors[int(phase) % n]
idx = int(phase)
frac = phase - idx
frac = _ease_color_frac(phase - idx, self._easing)
c1 = self._colors[idx % n]
c2 = self._colors[(idx + 1) % n]
return (
@@ -1466,6 +1546,7 @@ class ValueStreamManager:
gradient_store: Optional[Any] = None,
event_bus: Optional["GameEventBus"] = None,
audio_processing_template_store=None,
sync_clock_manager: Optional["SyncClockManager"] = None,
):
self._value_source_store = value_source_store
self._audio_capture_manager = audio_capture_manager
@@ -1477,8 +1558,12 @@ class ValueStreamManager:
self._gradient_store = gradient_store
self._event_bus = event_bus
self._audio_processing_template_store = audio_processing_template_store
self._sync_clock_manager = sync_clock_manager
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
# Tracks which clock_id (if any) was acquired for each stream so we
# can release/swap it without re-querying the store at teardown time.
self._stream_clock_ids: Dict[str, str] = {} # vs_id → clock_id
def acquire(self, vs_id: str) -> ValueStream:
"""Get or create a shared ValueStream for the given ValueSource.
@@ -1492,7 +1577,7 @@ class ValueStreamManager:
return self._streams[vs_id]
source = self._value_source_store.get_source(vs_id)
stream = self._create_stream(source)
stream = self._create_stream(source, vs_id)
stream.start()
self._streams[vs_id] = stream
self._ref_counts[vs_id] = 1
@@ -1512,6 +1597,7 @@ class ValueStreamManager:
if stream:
stream.stop()
del self._ref_counts[vs_id]
self._release_clock_for(vs_id)
logger.info(f"Released value stream {vs_id} (last ref)")
else:
logger.info(f"Released ref for value stream {vs_id} (refs={refs})")
@@ -1527,8 +1613,53 @@ class ValueStreamManager:
stream = self._streams.get(vs_id)
if stream:
stream.update_source(source)
self._sync_clock_binding(vs_id, source, stream)
logger.debug(f"Updated value stream {vs_id}")
def _sync_clock_binding(self, vs_id: str, source: "ValueSource", stream: ValueStream) -> None:
"""Hot-swap the sync-clock runtime attached to *stream* if needed."""
if not self._sync_clock_manager or not hasattr(stream, "set_clock"):
return
new_clock_id = getattr(source, "clock_id", None) or None
old_clock_id = self._stream_clock_ids.get(vs_id)
if new_clock_id == old_clock_id:
return
new_runtime = None
if new_clock_id:
try:
new_runtime = self._sync_clock_manager.acquire(new_clock_id)
except Exception as e:
logger.warning(
"Could not acquire sync clock %s for value stream %s: %s",
new_clock_id,
vs_id,
e,
)
new_runtime = None
new_clock_id = None
try:
stream.set_clock(new_runtime)
except Exception as e:
logger.warning("set_clock failed on value stream %s: %s", vs_id, e)
if new_clock_id:
self._stream_clock_ids[vs_id] = new_clock_id
else:
self._stream_clock_ids.pop(vs_id, None)
if old_clock_id:
try:
self._sync_clock_manager.release(old_clock_id)
except Exception as e:
logger.debug("Sync clock release for %s: %s", old_clock_id, e)
def _release_clock_for(self, vs_id: str) -> None:
"""Release the sync clock acquired for *vs_id* (if any)."""
clock_id = self._stream_clock_ids.pop(vs_id, None)
if clock_id and self._sync_clock_manager:
try:
self._sync_clock_manager.release(clock_id)
except Exception as e:
logger.debug("Sync clock release for %s: %s", clock_id, e)
def refresh_audio_filter_pipelines(self, template_id: str) -> None:
"""Rebuild audio filter pipelines for any running AudioValueStream
that references the given audio processing template ID.
@@ -1555,11 +1686,19 @@ class ValueStreamManager:
stream.stop()
except Exception as e:
logger.error(f"Error stopping value stream {vs_id}: {e}")
# Release any sync clocks held by streams.
if self._sync_clock_manager:
for vs_id, clock_id in self._stream_clock_ids.items():
try:
self._sync_clock_manager.release(clock_id)
except Exception as e:
logger.debug("Sync clock release for %s during shutdown: %s", clock_id, e)
self._stream_clock_ids.clear()
self._streams.clear()
self._ref_counts.clear()
logger.info("Released all value streams")
def _create_stream(self, source: "ValueSource") -> ValueStream:
def _create_stream(self, source: "ValueSource", vs_id: Optional[str] = None) -> ValueStream:
"""Factory: create the appropriate ValueStream for a ValueSource."""
from ledgrab.storage.value_source import (
AdaptiveValueSource,
@@ -1608,6 +1747,7 @@ class ValueStreamManager:
speed=source.speed,
use_real_time=source.use_real_time,
latitude=source.latitude,
longitude=source.longitude,
min_value=source.min_value,
max_value=source.max_value,
)
@@ -1634,10 +1774,24 @@ class ValueStreamManager:
return StaticColorValueStream(color=source.color)
if isinstance(source, AnimatedColorValueSource):
clock_runtime = None
if source.clock_id and self._sync_clock_manager:
try:
clock_runtime = self._sync_clock_manager.acquire(source.clock_id)
if vs_id is not None:
self._stream_clock_ids[vs_id] = source.clock_id
except Exception as e:
logger.warning(
"Could not acquire sync clock %s for value source %s: %s",
source.clock_id,
source.id,
e,
)
return AnimatedColorValueStream(
colors=source.colors,
speed=source.speed,
easing=source.easing,
clock=clock_runtime,
)
if isinstance(source, AdaptiveTimeColorValueSource):
@@ -1159,6 +1159,116 @@ textarea:focus-visible {
font-size: 0.9rem;
}
/* ── Timezone picker (Settings modal) ──────────────────────
Refined "instrument readout" trigger that matches the lux
sectional design language used in the rest of the modal:
- hairline border, deeper bg, monospaced offset chip
- region icon stenciled in --ch-cyan to echo section dots
- subtle hover glow on the channel hue, not the primary green */
.tz-picker-wrap {
position: relative;
display: block;
isolation: isolate;
}
.tz-picker-wrap .entity-select-trigger {
--tz-ch: var(--ch-cyan, var(--primary-color));
background: var(--lux-bg-1, var(--bg-color));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, 6px);
padding: 10px 12px;
gap: 12px;
font-feature-settings: "ss01", "tnum";
font-variant-numeric: tabular-nums;
color: var(--lux-ink, var(--text-color));
transition:
border-color 180ms var(--ease-out, ease-out),
background 180ms var(--ease-out, ease-out),
box-shadow 220ms var(--ease-out, ease-out);
}
.tz-picker-wrap .entity-select-trigger::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 2px;
background: linear-gradient(
to bottom,
color-mix(in srgb, var(--tz-ch) 80%, transparent),
color-mix(in srgb, var(--tz-ch) 0%, transparent) 70%
);
border-radius: var(--lux-r-md, 6px) 0 0 var(--lux-r-md, 6px);
opacity: 0.75;
pointer-events: none;
transition: opacity 220ms var(--ease-out, ease-out);
}
.tz-picker-wrap .entity-select-trigger:hover {
border-color: color-mix(in srgb, var(--tz-ch) 55%, var(--lux-line-bold, var(--border-color)));
background: var(--lux-bg-2, var(--bg-secondary));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tz-ch) 18%, transparent),
0 8px 24px -10px color-mix(in srgb, var(--tz-ch) 30%, transparent);
}
.tz-picker-wrap .entity-select-trigger:hover::before {
opacity: 1;
}
.tz-picker-wrap .entity-select-trigger:focus-visible {
outline: none;
border-color: var(--tz-ch);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--tz-ch) 25%, transparent);
}
.tz-picker-wrap .es-trigger-icon {
width: 28px;
height: 28px;
border-radius: var(--lux-r-sm, 3px);
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--tz-ch) 12%, transparent);
border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--tz-ch) 25%, transparent);
color: var(--tz-ch);
}
.tz-picker-wrap .es-trigger-icon .icon {
width: 14px;
height: 14px;
}
.tz-picker-wrap .es-trigger-label {
font-weight: 600;
letter-spacing: 0.01em;
color: var(--lux-ink, var(--text-color));
}
/* Render the offset segment after the city in monospaced "panel-meter" type */
.tz-picker-wrap .es-trigger-label {
/* Allow ellipsis to keep the trigger compact in narrow layouts. */
min-width: 0;
}
.tz-picker-wrap .es-trigger-arrow {
color: var(--lux-ink-mute, var(--text-secondary));
opacity: 0.7;
transform: translateY(-1px);
transition: transform 220ms var(--ease-out, ease-out), color 180ms ease;
}
.tz-picker-wrap:hover .es-trigger-arrow {
color: var(--tz-ch);
opacity: 1;
transform: translateY(0);
}
/* When the value is the system default (empty string), dim the label slightly
so users can see it is in the "no override" state at a glance. */
.tz-picker-wrap .entity-select-trigger:has(.es-trigger-none) {
border-style: dashed;
}
/* ── Scroll-to-top button ── */
.scroll-to-top {
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -66,7 +66,7 @@ import {
import {
loadPictureSources, switchStreamTab,
showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate,
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest,
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest, openTestDisplayPicker,
showAddAudioTemplateModal, editAudioTemplate, closeAudioTemplateModal, saveAudioTemplate, deleteAudioTemplate,
cloneAudioTemplate, onAudioEngineChange,
showTestAudioTemplateModal, closeTestAudioTemplateModal, startAudioTemplateTest,
@@ -131,7 +131,6 @@ import {
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onEffectTypeChange, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
colorCycleAddColor, colorCycleRemoveColor,
compositeAddLayer, compositeRemoveLayer,
mappedAddZone, mappedRemoveZone,
onAudioVizChange,
@@ -224,6 +223,7 @@ import {
openLogOverlay, closeLogOverlay,
loadLogLevel, setLogLevel,
loadShutdownAction, setShutdownAction,
loadDaylightTimezone, saveDaylightTimezone,
requestNotifPermissionFromSettings, testNotifFromSettings,
saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
} from './features/settings.ts';
@@ -340,6 +340,7 @@ Object.assign(window, {
closeTestTemplateModal,
onEngineChange,
runTemplateTest,
openTestDisplayPicker,
updateCaptureDuration,
showAddStreamModal,
editStream,
@@ -488,8 +489,6 @@ Object.assign(window, {
onCSSClockChange,
onAnimationTypeChange,
onDaylightRealTimeChange,
colorCycleAddColor,
colorCycleRemoveColor,
compositeAddLayer,
compositeRemoveLayer,
mappedAddZone,
@@ -630,6 +629,8 @@ Object.assign(window, {
setLogLevel,
loadShutdownAction,
setShutdownAction,
loadDaylightTimezone,
saveDaylightTimezone,
requestNotifPermissionFromSettings,
testNotifFromSettings,
saveExternalUrl,
@@ -0,0 +1,255 @@
/**
* Daylight-cycle timezone picker.
*
* Exposes:
* - `getTimezoneItems()` IconSelectItem[] for the EntityPalette / EntitySelect
* - `enhanceTimezoneSelect(target, value, onChange?)` replace a `<select>` with
* a refined EntitySelect picker. Idempotent; safe to call on every modal open.
* - `populateTimezoneSelect(id, value)` back-compat for legacy callers that
* still want a plain native `<select>`.
*
* Each entry exposes a friendly city name and the timezone's *current* UTC
* offset (computed via `Intl.DateTimeFormat`), so users see `Berlin · UTC+02:00`
* instead of bare IANA strings like `Europe/Berlin`.
*/
import { t } from './i18n.ts';
import {
ICON_GLOBE,
ICON_SPARKLES,
ICON_TARGET_ICON,
ICON_CLOCK,
} from './icons.ts';
import type { IconSelectItem } from './icon-select.ts';
import { EntitySelect } from './entity-palette.ts';
type RegionKey =
| 'utc'
| 'europe'
| 'africa'
| 'me'
| 'asia'
| 'pacific'
| 'americas';
interface TimezoneEntry {
iana: string;
region: RegionKey;
city: string;
}
const TIMEZONES: ReadonlyArray<TimezoneEntry> = [
{ iana: 'UTC', region: 'utc', city: 'UTC' },
// Europe
{ iana: 'Europe/London', region: 'europe', city: 'London' },
{ iana: 'Europe/Lisbon', region: 'europe', city: 'Lisbon' },
{ iana: 'Europe/Paris', region: 'europe', city: 'Paris' },
{ iana: 'Europe/Berlin', region: 'europe', city: 'Berlin' },
{ iana: 'Europe/Warsaw', region: 'europe', city: 'Warsaw' },
{ iana: 'Europe/Helsinki', region: 'europe', city: 'Helsinki' },
{ iana: 'Europe/Athens', region: 'europe', city: 'Athens' },
{ iana: 'Europe/Moscow', region: 'europe', city: 'Moscow' },
{ iana: 'Europe/Istanbul', region: 'europe', city: 'Istanbul' },
// Africa & Middle East
{ iana: 'Africa/Cairo', region: 'africa', city: 'Cairo' },
{ iana: 'Africa/Lagos', region: 'africa', city: 'Lagos' },
{ iana: 'Africa/Johannesburg', region: 'africa', city: 'Johannesburg' },
{ iana: 'Asia/Dubai', region: 'me', city: 'Dubai' },
{ iana: 'Asia/Tehran', region: 'me', city: 'Tehran' },
// Asia
{ iana: 'Asia/Karachi', region: 'asia', city: 'Karachi' },
{ iana: 'Asia/Kolkata', region: 'asia', city: 'Kolkata' },
{ iana: 'Asia/Bangkok', region: 'asia', city: 'Bangkok' },
{ iana: 'Asia/Jakarta', region: 'asia', city: 'Jakarta' },
{ iana: 'Asia/Singapore', region: 'asia', city: 'Singapore' },
{ iana: 'Asia/Hong_Kong', region: 'asia', city: 'Hong Kong' },
{ iana: 'Asia/Shanghai', region: 'asia', city: 'Shanghai' },
{ iana: 'Asia/Seoul', region: 'asia', city: 'Seoul' },
{ iana: 'Asia/Tokyo', region: 'asia', city: 'Tokyo' },
// Pacific
{ iana: 'Australia/Perth', region: 'pacific', city: 'Perth' },
{ iana: 'Australia/Adelaide', region: 'pacific', city: 'Adelaide' },
{ iana: 'Australia/Sydney', region: 'pacific', city: 'Sydney' },
{ iana: 'Pacific/Auckland', region: 'pacific', city: 'Auckland' },
{ iana: 'Pacific/Honolulu', region: 'pacific', city: 'Honolulu' },
// Americas
{ iana: 'America/Anchorage', region: 'americas', city: 'Anchorage' },
{ iana: 'America/Los_Angeles', region: 'americas', city: 'Los Angeles' },
{ iana: 'America/Denver', region: 'americas', city: 'Denver' },
{ iana: 'America/Chicago', region: 'americas', city: 'Chicago' },
{ iana: 'America/Mexico_City', region: 'americas', city: 'Mexico City' },
{ iana: 'America/New_York', region: 'americas', city: 'New York' },
{ iana: 'America/Toronto', region: 'americas', city: 'Toronto' },
{ iana: 'America/Sao_Paulo', region: 'americas', city: 'São Paulo' },
{ iana: 'America/Argentina/Buenos_Aires', region: 'americas', city: 'Buenos Aires' },
];
const REGION_FALLBACK: Record<RegionKey, string> = {
utc: 'UTC',
europe: 'Europe',
africa: 'Africa',
me: 'Middle East',
asia: 'Asia',
pacific: 'Pacific',
americas: 'Americas',
};
function _detectedTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
} catch {
return '';
}
}
/** Compute the current UTC offset for a given IANA zone, formatted as `UTC+02:00`. */
function _utcOffsetLabel(iana: string): string {
try {
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: iana,
timeZoneName: 'longOffset',
});
const parts = fmt.formatToParts(new Date());
const raw = parts.find(p => p.type === 'timeZoneName')?.value || '';
// Intl returns 'GMT+02:00' for offsets and 'GMT' for UTC. Normalize.
if (raw === 'GMT' || raw === '') return 'UTC';
return raw.replace(/^GMT/, 'UTC');
} catch {
return '';
}
}
function _friendlyCityFromIana(iana: string): string {
const last = iana.split('/').pop() || iana;
return last.replace(/_/g, ' ');
}
function _regionLabel(region: RegionKey): string {
return t(`common.daylight_tz.region.${region}`) || REGION_FALLBACK[region];
}
function _formatRowLabel(city: string, offset: string): string {
// Em-dash separator, monospaced offset rendered via CSS on the popup.
return offset ? `${city} · ${offset}` : city;
}
/** Build the list of items for an EntitySelect timezone picker. */
export function getTimezoneItems(): IconSelectItem[] {
const items: IconSelectItem[] = [];
const detected = _detectedTimezone();
items.push({
value: '',
icon: ICON_TARGET_ICON,
label: t('common.daylight_tz.system') || 'System default',
desc: t('common.daylight_tz.system_desc') || 'Server clock',
});
if (detected) {
const offset = _utcOffsetLabel(detected);
const city = _friendlyCityFromIana(detected);
const detectedLabel = t('common.daylight_tz.detected_label') || 'Auto-detected';
items.push({
value: detected,
icon: ICON_SPARKLES,
label: _formatRowLabel(city, offset),
desc: `${detectedLabel} · ${detected}`,
});
}
for (const tz of TIMEZONES) {
if (detected && tz.iana === detected) continue;
const offset = _utcOffsetLabel(tz.iana);
const region = _regionLabel(tz.region);
items.push({
value: tz.iana,
icon: tz.region === 'utc' ? ICON_CLOCK : ICON_GLOBE,
label: _formatRowLabel(tz.city, offset),
desc: `${region} · ${tz.iana}`,
});
}
return items;
}
/**
* Populate a `<select>` with timezone options. Kept for back-compat; the new
* preferred entrypoint is `enhanceTimezoneSelect()`.
*/
export function populateTimezoneSelect(selectId: string, currentValue: string): void {
const select = document.getElementById(selectId) as HTMLSelectElement | null;
if (!select) return;
_syncNativeOptions(select, currentValue);
}
function _syncNativeOptions(select: HTMLSelectElement, currentValue: string): void {
const items = getTimezoneItems();
const seen = new Set<string>();
const fragments: string[] = [];
for (const item of items) {
if (seen.has(item.value)) continue;
seen.add(item.value);
const text = item.desc ? `${item.label}${item.desc}` : item.label;
fragments.push(`<option value="${_escapeAttr(item.value)}">${_escapeText(text)}</option>`);
}
if (currentValue && !seen.has(currentValue)) {
fragments.unshift(
`<option value="${_escapeAttr(currentValue)}">${_escapeText(currentValue)}</option>`,
);
}
select.innerHTML = fragments.join('');
select.value = currentValue || '';
}
function _escapeAttr(s: string): string {
return s.replace(/[&<>"']/g, c =>
c === '&' ? '&amp;'
: c === '<' ? '&lt;'
: c === '>' ? '&gt;'
: c === '"' ? '&quot;'
: '&#39;',
);
}
function _escapeText(s: string): string {
return _escapeAttr(s);
}
const _enhanced = new WeakMap<HTMLSelectElement, EntitySelect>();
/**
* Replace a native `<select>` with the refined timezone EntitySelect picker.
* Idempotent calling on the same element again refreshes items and value.
*/
export function enhanceTimezoneSelect(
target: HTMLSelectElement,
currentValue: string,
onChange?: (value: string) => void,
): EntitySelect {
_syncNativeOptions(target, currentValue);
const existing = _enhanced.get(target);
if (existing) {
existing.refresh();
existing.setValue(currentValue || '');
return existing;
}
const trigger = target.parentElement?.classList.contains('tz-picker-wrap');
if (trigger) {
// Hint for stylesheet: distinguish the timezone trigger from generic ones.
target.parentElement!.dataset.tzPicker = '';
}
const es = new EntitySelect({
target,
getItems: getTimezoneItems,
placeholder: t('common.daylight_tz.search') || 'Search timezones…',
onChange,
});
_enhanced.set(target, es);
return es;
}
@@ -92,7 +92,7 @@ const KIND_ICONS = {
// ── Subtype-specific icon overrides ──
const SUBTYPE_ICONS = {
color_strip_source: {
picture_advanced: P.monitor, static: P.palette, color_cycle: P.refreshCw,
picture_advanced: P.monitor, static: P.palette,
gradient: P.rainbow, effect: P.zap, composite: P.link,
mapped: P.mapPin, mapped_zones: P.mapPin,
audio: P.music, audio_visualization: P.music,
@@ -124,3 +124,13 @@ export const chevronDown = '<path d="m6 9 6 6 6-6"/>';
export const plus = '<path d="M5 12h14"/><path d="M12 5v14"/>';
// Lucide: git-merge (sequence mode icon)
export const gitMerge = '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>';
// Easing curve glyphs — custom mini-charts that draw the actual curve.
// Curve travels from (4, 20) to (20, 4); each path renders the easing
// function directly so the picker shows the shape, not a metaphor.
export const easingLinear = '<path d="M4 20 20 4"/>';
export const easingStep = '<path d="M4 20h8v-8h8V4"/>';
export const easingIn = '<path d="M4 20C13 20 16 18 20 4"/>';
export const easingOut = '<path d="M4 20C8 6 11 4 20 4"/>';
export const easingInOut = '<path d="M4 20C12 20 12 4 20 4"/>';
export const easingSine = '<path d="M4 12C7 12 8 4 12 4S17 12 20 12"/>';
+1 -1
View File
@@ -18,7 +18,7 @@ const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb
const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image), video: _svg(P.film) };
const _colorStripTypeIcons = {
picture_advanced: _svg(P.monitor),
static: _svg(P.palette), color_cycle: _svg(P.refreshCw), gradient: _svg(P.rainbow),
static: _svg(P.palette), gradient: _svg(P.rainbow),
effect: _svg(P.zap), composite: _svg(P.link),
mapped: _svg(P.mapPin), mapped_zones: _svg(P.mapPin),
audio: _svg(P.music), audio_visualization: _svg(P.music),
+23 -7
View File
@@ -36,11 +36,14 @@ export class Modal {
open() {
this._previousFocus = document.activeElement;
// Cancel any in-flight close animation so the modal becomes interactive again.
// If a close animation is in flight, we still own the body lock — it
// gets released in finalize(). Skip the lockBody() call in that case
// so the lock count stays balanced.
const hadInFlightClose = this._exitCleanup !== null;
this.el!.classList.remove('closing');
this._cancelExitAnim();
this.el!.style.display = 'flex';
if (this._lock) lockBody();
if (this._lock && !hadInFlightClose) lockBody();
if (this._backdrop) setupBackdropClose(this.el!, () => this.close());
trapFocus(this.el!);
Modal._stack = Modal._stack.filter(m => m !== this);
@@ -72,12 +75,14 @@ export class Modal {
if (this.el.classList.contains('closing')) return;
// Modal-state cleanup happens immediately so subsequent code that
// queries Modal._stack / focus / body-lock sees a "closed" state.
// queries Modal._stack / focus sees a "closed" state. Body-lock
// release AND subclass cleanup (onForceClose) are deferred to
// finalize() — running them now would toggle html.modal-open or
// mutate DOM (widget destruction clears innerHTML) before the exit
// animation, causing a visible layout shift inside the modal.
releaseFocus(this.el);
if (this._lock) unlockBody();
this._initialValues = {};
this.hideError();
this.onForceClose();
Modal._stack = Modal._stack.filter(m => m !== this);
if (this._previousFocus && typeof (this._previousFocus as HTMLElement).focus === 'function') {
(this._previousFocus as HTMLElement).focus({ preventScroll: true });
@@ -95,10 +100,13 @@ export class Modal {
const finalize = () => {
this._exitCleanup = null;
// Guard against re-open: if open() was called during animation,
// the .closing class is gone and display is already 'flex'.
// the .closing class is gone and display is already 'flex' — the
// body lock is still held by the re-opened modal, so don't release.
if (!el.classList.contains('closing')) return;
el.classList.remove('closing');
el.style.display = 'none';
this.onForceClose();
if (this._lock) unlockBody();
};
let timer: number | null = null;
@@ -110,6 +118,9 @@ export class Modal {
this._exitCleanup = () => {
if (timer !== null) { clearTimeout(timer); timer = null; }
content?.removeEventListener('animationend', onEnd);
// Re-open during animation: still run subclass cleanup so the
// fresh open re-initializes from a clean slate (widgets etc.).
this.onForceClose();
};
el.classList.add('closing');
@@ -156,7 +167,12 @@ export class Modal {
if (this.errorEl) this.errorEl.style.display = 'none';
}
/** Hook for subclass cleanup on force-close (canvas, observers, etc.). */
/**
* Hook for subclass cleanup on force-close (canvas, observers, widget
* .destroy() calls, etc.). Runs AFTER the exit animation completes (or
* when an in-flight close is canceled by reopen), so DOM mutations
* here do not cause a layout shift visible during the animation.
*/
onForceClose() {}
$(id: string) {
@@ -290,6 +290,12 @@ export const captureTemplatesCache = new DataCache<CaptureTemplate[]>({
});
captureTemplatesCache.subscribe(v => { _cachedCaptureTemplates = v; });
export const enginesCache = new DataCache<EngineInfo[]>({
endpoint: '/capture-engines',
extractData: json => json.engines || [],
});
enginesCache.subscribe(v => { availableEngines = v; });
export const audioSourcesCache = new DataCache<AudioSource[]>({
endpoint: '/audio-sources',
extractData: json => json.sources || [],
@@ -47,7 +47,7 @@ function _gradientEntityStripHTML(stops: Array<{ position: number; color: number
/* ── Non-picture types set ────────────────────────────────────── */
const NON_PICTURE_TYPES = new Set([
'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped',
'static', 'gradient', 'effect', 'composite', 'mapped',
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
'math_wave',
]);
@@ -65,16 +65,6 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
${clockBadge}
`;
},
color_cycle: (source, { clockBadge }) => {
const colors = source.colors || [];
const swatches = colors.slice(0, 8).map((c: any) =>
`<span style="display:inline-block;width:12px;height:12px;background:${rgbArrayToHex(c)};border:1px solid #888;border-radius:2px;margin-right:2px"></span>`
).join('');
return `
<span class="stream-card-prop">${swatches}</span>
${clockBadge}
`;
},
gradient: (source, { clockBadge, animBadge }) => {
const stops = source.stops || [];
const sortedStops = [...stops].sort((a, b) => a.position - b.position);
@@ -277,7 +267,6 @@ function _renderPictureCardProps(source: ColorStripSource, pictureSourceMap: Rec
const STRIP_BADGE: Record<string, string> = {
static: 'STRIP · COLOR',
gradient: 'STRIP · GRD',
color_cycle: 'STRIP · CYCLE',
effect: 'STRIP · FX',
composite: 'STRIP · COMP',
mapped: 'STRIP · MAP',
@@ -1,68 +0,0 @@
/**
* Color Strip Sources Color Cycle helpers.
* Extracted from color-strips.ts to reduce file size.
*/
import { ICON_TRASH } from '../../core/icons.ts';
import { rgbArrayToHex, hexToRgbArray } from '../css-gradient-editor.ts';
/* ── State ────────────────────────────────────────────────────── */
const _DEFAULT_CYCLE_COLORS = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff'];
let _colorCycleColors = [..._DEFAULT_CYCLE_COLORS];
/* ── DOM sync ─────────────────────────────────────────────────── */
function _syncColorCycleFromDom() {
const inputs = document.querySelectorAll<HTMLInputElement>('#color-cycle-colors-list input[type=color]');
if (inputs.length > 0) {
_colorCycleColors = Array.from(inputs).map(el => el.value);
}
}
/* ── Rendering ────────────────────────────────────────────────── */
function _colorCycleRenderList() {
const list = document.getElementById('color-cycle-colors-list') as HTMLElement | null;
if (!list) return;
const canRemove = _colorCycleColors.length > 2;
list.innerHTML = _colorCycleColors.map((hex, i) => `
<div class="color-cycle-item">
<input type="color" value="${hex}">
${canRemove
? `<button type="button" class="btn btn-secondary color-cycle-remove-btn"
onclick="colorCycleRemoveColor(${i})">${ICON_TRASH}</button>`
: `<div style="height:14px"></div>`}
</div>
`).join('') + `<div class="color-cycle-item"><button type="button" class="btn btn-secondary color-cycle-add-btn" onclick="colorCycleAddColor()">+</button></div>`;
}
/* ── Public actions ───────────────────────────────────────────── */
export function colorCycleAddColor() {
_syncColorCycleFromDom();
_colorCycleColors.push('#ffffff');
_colorCycleRenderList();
}
export function colorCycleRemoveColor(i: number) {
_syncColorCycleFromDom();
if (_colorCycleColors.length <= 2) return;
_colorCycleColors.splice(i, 1);
_colorCycleRenderList();
}
export function colorCycleGetColors() {
const inputs = document.querySelectorAll<HTMLInputElement>('#color-cycle-colors-list input[type=color]');
return Array.from(inputs).map(el => hexToRgbArray(el.value));
}
/* ── Load / Reset ─────────────────────────────────────────────── */
export function loadColorCycleState(css: any) {
const raw = css && css.colors;
_colorCycleColors = (raw && raw.length >= 2)
? raw.map((c: any) => rgbArrayToHex(c))
: [..._DEFAULT_CYCLE_COLORS];
_colorCycleRenderList();
}
@@ -1,6 +1,6 @@
/**
* Color Strip Sources CRUD, editor orchestration, type switching.
* Sub-modules handle type-specific logic: audio, cards, color-cycle, composite,
* Sub-modules handle type-specific logic: audio, cards, composite,
* game-event, gradient, mapped, math-wave, notification, test.
*/
@@ -62,9 +62,6 @@ import {
mappedSetAvailableSources, mappedRenderList, mappedAddZone, mappedRemoveZone,
mappedGetZones, loadMappedState, resetMappedState, getMappedZones,
} from './mapped.ts';
import {
colorCycleAddColor, colorCycleRemoveColor, colorCycleGetColors, loadColorCycleState,
} from './color-cycle.ts';
import {
destroyAudioWidgets, ensureAudioSensitivityWidget, ensureAudioSmoothingWidget,
ensureAudioBeatDecayWidget, ensureAudioColorWidget, ensureAudioColorPeakWidget,
@@ -90,11 +87,10 @@ export {
notificationAddAppOverride, notificationRemoveAppOverride,
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
};
export { _getAnimationPayload, colorCycleGetColors as _colorCycleGetColors };
export { _getAnimationPayload };
export { addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange };
export { mathWaveAddLayer, mathWaveRemoveLayer };
export { mappedAddZone, mappedRemoveZone };
export { colorCycleAddColor, colorCycleRemoveColor };
export { createColorStripCard };
export {
onGradientPresetChange, promptAndSaveGradientPreset, deleteAndRefreshGradientPreset,
@@ -158,7 +154,6 @@ class CSSEditorModal extends Modal {
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
animation_type: (document.getElementById('css-editor-animation-type') as HTMLInputElement).value,
cycle_colors: JSON.stringify(colorCycleGetColors()),
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
effect_palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
effect_color: _effectColorWidget ? JSON.stringify(_effectColorWidget.getValue()) : '[]',
@@ -190,6 +185,7 @@ class CSSEditorModal extends Modal {
daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value,
daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
daylight_latitude: (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value,
daylight_longitude: (document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value,
candlelight_color: _candlelightColorWidget ? JSON.stringify(_candlelightColorWidget.getValue()) : '[]',
candlelight_intensity: _candlelightIntensityWidget ? JSON.stringify(_candlelightIntensityWidget.getValue()) : '1.0',
candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value,
@@ -307,7 +303,7 @@ async function configureKCRegions(sourceId: string): Promise<void> {
// ══════════════════════════════════════════════════════════════════
const CSS_TYPE_KEYS = [
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
'picture', 'picture_advanced', 'static', 'gradient',
'effect', 'composite', 'mapped', 'audio',
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
'game_event', 'math_wave',
@@ -346,7 +342,6 @@ const CSS_SECTION_MAP: Record<string, string> = {
'picture': 'css-editor-picture-section',
'picture_advanced': 'css-editor-picture-section',
'static': 'css-editor-static-section',
'color_cycle': 'css-editor-color-cycle-section',
'gradient': 'css-editor-gradient-section',
'effect': 'css-editor-effect-section',
'composite': 'css-editor-composite-section',
@@ -422,7 +417,7 @@ export function onCSSTypeChange() {
(document.getElementById('css-editor-led-count-group') as HTMLElement).style.display =
hasLedCount.includes(type) ? '' : 'none';
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
if (clockTypes.includes(type)) _populateClockDropdown();
@@ -1006,15 +1001,6 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
return { name, color: _ensureStaticColorWidget().getValue(), animation: _getAnimationPayload() };
},
},
color_cycle: {
load(css) { loadColorCycleState(css); },
reset() { loadColorCycleState(null); },
getPayload(name) {
const cycleColors = colorCycleGetColors();
if (cycleColors.length < 2) { cssEditorModal.showError(t('color_strip.color_cycle.min_colors')); return null; }
return { name, colors: cycleColors };
},
},
gradient: {
load(css) {
const gradientId = css.gradient_id || '';
@@ -1180,6 +1166,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
(document.getElementById('css-editor-daylight-latitude-val') as HTMLElement).textContent = '50';
(document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value = 0.0 as any;
(document.getElementById('css-editor-daylight-longitude-val') as HTMLElement).textContent = '0';
_syncDaylightSpeedVisibility();
},
getPayload(name) {
return {
@@ -1563,7 +1550,7 @@ export async function saveCSSEditor() {
payload.source_type = knownType ? sourceType : 'picture';
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
if (clockTypes.includes(sourceType)) {
payload.clock_id = (document.getElementById('css-editor-clock') as HTMLInputElement).value || null;
}
@@ -15,14 +15,14 @@ import {
} from '../../core/icons.ts';
import { EntitySelect } from '../../core/entity-palette.ts';
import { hexToRgbArray, getGradientStops } from '../css-gradient-editor.ts';
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './index.ts';
import { testNotification, _getAnimationPayload } from './index.ts';
import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue, getNotificationVolumeValue } from './notification.ts';
import { openAuthedWs } from '../../core/ws-auth.ts';
/* ── Preview config builder ───────────────────────────────────── */
const _PREVIEW_TYPES = new Set([
'static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'notification',
'static', 'gradient', 'effect', 'daylight', 'candlelight', 'notification',
]);
function _collectPreviewConfig() {
@@ -35,10 +35,6 @@ function _collectPreviewConfig() {
const stops = getGradientStops();
if (stops.length < 2) return null;
config = { source_type: 'gradient', stops: stops.map(s => ({ position: s.position, color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}) })), animation: _getAnimationPayload(), easing: (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value };
} else if (sourceType === 'color_cycle') {
const colors = _colorCycleGetColors();
if (colors.length < 2) return null;
config = { source_type: 'color_cycle', colors };
} else if (sourceType === 'effect') {
config = { source_type: 'effect', effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value), scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value), mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked };
if (['meteor', 'comet', 'bouncing_ball'].includes(config.effect_type)) { const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
@@ -323,9 +323,11 @@ function _renderIntegrationCard(conn: HomeAssistantConnectionStatus): string {
? `${t('ha_source.connected')}${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected');
const statusDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
const host = (conn.host || '').trim();
const entitiesPart = `${conn.entity_count} ${t('dashboard.integrations.entities')}`;
const subtitle = conn.connected
? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected');
? (host ? `${host} · ${entitiesPart}` : entitiesPart)
: (host ? `${host} · ${t('ha_source.disconnected')}` : t('ha_source.disconnected'));
const short = (conn.source_id || '').replace(/^src_/, '').slice(0, 2).toUpperCase() || 'HA';
const ledCls = conn.connected ? 'led on blink' : 'led';
const patchLabel = conn.connected ? 'ONLINE' : 'OFFLINE';
@@ -403,9 +405,11 @@ function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttS
}
const meta = card.querySelector('.mod-meta');
if (meta) {
const host = (conn.host || '').trim();
const entitiesPart = `${conn.entity_count} ${t('dashboard.integrations.entities')}`;
meta.textContent = conn.connected
? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected');
? (host ? `${host} · ${entitiesPart}` : entitiesPart)
: (host ? `${host} · ${t('ha_source.disconnected')}` : t('ha_source.disconnected'));
}
applyState(card, conn.connected, conn.connected ? 'ONLINE' : 'OFFLINE');
}
@@ -25,33 +25,61 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
_pickerEngineType = engineType || null;
const lightbox = document.getElementById('display-picker-lightbox')!;
const canvas = document.getElementById('display-picker-canvas')!;
// Use "Select a Device" title for engines with own display lists (camera, scrcpy, etc.)
const titleEl = lightbox.querySelector('.display-picker-title');
if (titleEl) {
titleEl.textContent = t(_pickerEngineType ? 'displays.picker.title.device' : 'displays.picker.title');
// Device-picker variants (camera, scrcpy) drop the big "Select Display"
// title — the eyebrow channel strip already reads "Device · List", so
// a redundant title rack just steals vertical space. The title only
// renders in monitor-pick mode.
const isDevicePicker = !!_pickerEngineType;
const content = lightbox.querySelector('.display-picker-content');
content?.classList.toggle('display-picker-content--no-title', isDevicePicker);
const setI18n = (selector: string, key: string) => {
const el = lightbox.querySelector(selector);
if (!el) return;
el.setAttribute('data-i18n', key);
el.textContent = t(key);
};
if (!isDevicePicker) {
setI18n('.display-picker-title [data-role="lead"]', 'displays.picker.title.lead');
setI18n('.display-picker-title__accent', 'displays.picker.title.accent');
}
setI18n('.display-picker-eyebrow [data-role="label"]', 'displays.picker.eyebrow.label');
setI18n(
'.display-picker-eyebrow__channel',
isDevicePicker ? 'displays.picker.eyebrow.channel.device' : 'displays.picker.eyebrow.channel',
);
setI18n(
'.display-picker-foot [data-role="select"]',
isDevicePicker ? 'displays.picker.foot.select.device' : 'displays.picker.foot.select',
);
// Render synchronously *before* activating the modal so the first frame
// shows final content (or a spinner) instead of stale layout from the
// previous open.
if (_pickerEngineType) {
canvas.innerHTML = '<div class="loading-spinner"></div>';
} else if (_cachedDisplays && _cachedDisplays.length > 0) {
renderDisplayPickerLayout(_cachedDisplays);
} else {
canvas.innerHTML = '<div class="loading-spinner"></div>';
}
lightbox.classList.add('active');
requestAnimationFrame(() => {
// Always fetch fresh when engine type is specified (different list each time)
if (_pickerEngineType) {
_fetchAndRenderEngineDisplays(_pickerEngineType);
} else if (_cachedDisplays && _cachedDisplays.length > 0) {
renderDisplayPickerLayout(_cachedDisplays);
} else {
const canvas = document.getElementById('display-picker-canvas')!;
canvas.innerHTML = '<div class="loading-spinner"></div>';
displaysCache.fetch().then(displays => {
if (displays && displays.length > 0) {
renderDisplayPickerLayout(displays);
} else {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
}
});
}
});
// Kick off async fetches after activation; spinner is already in place.
if (_pickerEngineType) {
_fetchAndRenderEngineDisplays(_pickerEngineType);
} else if (!_cachedDisplays || _cachedDisplays.length === 0) {
displaysCache.fetch().then(displays => {
if (displays && displays.length > 0) {
renderDisplayPickerLayout(displays);
} else {
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
}
});
}
}
async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void> {
@@ -525,6 +525,7 @@ export function openSettingsModal(): void {
loadBackupList();
loadLogLevel();
loadShutdownAction();
loadDaylightTimezone();
_seedRailFooter();
// Refresh the update status so the rail badge ("update available" pill
// on the Updates tab) is current when the modal opens — it would
@@ -975,6 +976,45 @@ export async function setShutdownAction(): Promise<void> {
}
}
// ─── Daylight timezone (global) ───────────────────────────────
export async function loadDaylightTimezone(): Promise<void> {
const select = document.getElementById('settings-daylight-timezone') as HTMLSelectElement | null;
if (!select) return;
let current = '';
try {
const resp = await fetchWithAuth('/preferences/daylight-timezone');
if (resp.ok) {
const data = await resp.json();
current = typeof data?.timezone === 'string' ? data.timezone : '';
}
} catch (err) {
console.error('Failed to load daylight timezone:', err);
}
const { enhanceTimezoneSelect } = await import('../core/daylight-tz.ts');
enhanceTimezoneSelect(select, current, () => { saveDaylightTimezone(); });
}
export async function saveDaylightTimezone(): Promise<void> {
const select = document.getElementById('settings-daylight-timezone') as HTMLSelectElement | null;
if (!select) return;
const tz = select.value;
try {
const resp = await fetchWithAuth('/preferences/daylight-timezone', {
method: 'PUT',
body: JSON.stringify({ timezone: tz }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('settings.daylight_timezone.saved'), 'success');
} catch (err) {
console.error('Failed to save daylight timezone:', err);
showToast(t('settings.daylight_timezone.save_error') + ': ' + err.message, 'error');
}
}
// ─── Notifications tab ────────────────────────────────────────
const _NOTIF_EVENT_KEYS = [
@@ -4,12 +4,12 @@
*/
import {
availableEngines, setAvailableEngines,
availableEngines,
currentEditingTemplateId, setCurrentEditingTemplateId,
_templateNameManuallyEdited, set_templateNameManuallyEdited,
currentTestingTemplate, setCurrentTestingTemplate,
_cachedStreams, _cachedDisplays,
captureTemplatesCache, displaysCache,
captureTemplatesCache, displaysCache, enginesCache,
apiKey,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
@@ -168,6 +168,7 @@ export async function showTestTemplateModal(templateId: any) {
}
setCurrentTestingTemplate(template);
await enginesCache.fetch();
await loadDisplaysForTest();
restoreCaptureDuration();
@@ -186,10 +187,7 @@ export function closeTestTemplateModal() {
async function loadAvailableEngines() {
try {
const response = await fetchWithAuth('/capture-engines');
if (!response.ok) throw new Error(`Failed to load engines: ${response.status}`);
const data = await response.json();
setAvailableEngines(data.engines || []);
await enginesCache.fetch();
const select = document.getElementById('template-engine') as HTMLSelectElement;
select.innerHTML = '';
@@ -224,6 +222,7 @@ async function loadAvailableEngines() {
}
let _engineIconSelect: IconSelect | null = null;
const _configIconSelects: Map<string, IconSelect> = new Map();
export async function onEngineChange() {
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
@@ -249,16 +248,16 @@ export async function onEngineChange() {
hint.style.display = 'none';
}
for (const inst of _configIconSelects.values()) inst.destroy();
_configIconSelects.clear();
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
const configChoices: Record<string, string[]> = engine.config_choices || {};
// Known select options for specific config keys
const CONFIG_SELECT_OPTIONS = {
camera_backend: ['auto', 'dshow', 'msmf', 'v4l2'],
};
// IconSelect definitions for specific config keys
const CONFIG_ICON_SELECT = {
// Full catalogue of icon-select renderings for known enum-like config keys.
// The actual list of options shown is intersected with engine.config_choices
// so platform-specific values (e.g. v4l2 on Windows) are hidden by the server.
const CONFIG_ICON_SELECT_CATALOGUE: Record<string, { columns: number; items: { value: string; icon: string; label: string; desc: string }[] }> = {
camera_backend: {
columns: 2,
items: [
@@ -268,8 +267,31 @@ export async function onEngineChange() {
{ value: 'v4l2', icon: _icon(P.monitor), label: 'V4L2', desc: t('templates.config.camera_backend.v4l2') },
],
},
resolution: {
columns: 2,
items: [
{ value: 'auto', icon: _icon(P.slidersHorizontal), label: 'Auto', desc: t('templates.config.resolution.auto') },
{ value: '640x480', icon: _icon(P.monitor), label: '640×480', desc: t('templates.config.resolution.480p') },
{ value: '1280x720', icon: _icon(P.monitor), label: '1280×720', desc: t('templates.config.resolution.720p') },
{ value: '1920x1080', icon: _icon(P.tv), label: '1920×1080', desc: t('templates.config.resolution.1080p') },
{ value: '2560x1440', icon: _icon(P.tv), label: '2560×1440', desc: t('templates.config.resolution.1440p') },
{ value: '3840x2160', icon: _icon(P.tv), label: '3840×2160', desc: t('templates.config.resolution.2160p') },
],
},
};
const CONFIG_SELECT_OPTIONS: Record<string, string[]> = {};
const CONFIG_ICON_SELECT: Record<string, { columns: number; items: { value: string; icon: string; label: string; desc: string }[] }> = {};
for (const [key, choices] of Object.entries(configChoices)) {
if (!choices || choices.length === 0) continue;
CONFIG_SELECT_OPTIONS[key] = choices;
const cat = CONFIG_ICON_SELECT_CATALOGUE[key];
if (cat) {
const filtered = cat.items.filter(item => choices.includes(item.value));
if (filtered.length > 0) CONFIG_ICON_SELECT[key] = { columns: cat.columns, items: filtered };
}
}
if (Object.keys(defaultConfig).length === 0) {
configSection.style.display = 'none';
return;
@@ -303,7 +325,10 @@ export async function onEngineChange() {
// Apply IconSelect to known config selects
for (const [key, cfg] of Object.entries(CONFIG_ICON_SELECT)) {
const sel = document.getElementById(`config-${key}`);
if (sel) new IconSelect({ target: sel as HTMLSelectElement, items: cfg.items, columns: cfg.columns });
if (sel) {
const inst = new IconSelect({ target: sel as HTMLSelectElement, items: cfg.items, columns: cfg.columns });
_configIconSelects.set(key, inst);
}
}
}
@@ -315,7 +340,9 @@ function populateEngineConfig(config: any) {
const field = document.getElementById(`config-${key}`) as HTMLInputElement | HTMLSelectElement | null;
if (field) {
if (field.tagName === 'SELECT') {
field.value = value.toString();
const iconSel = _configIconSelects.get(key);
if (iconSel) iconSel.setValue(value.toString());
else field.value = value.toString();
} else {
field.value = value;
}
@@ -339,11 +366,41 @@ function collectEngineConfig() {
return config;
}
function _engineHasOwnDisplays(engineType: string | undefined | null): boolean {
if (!engineType) return false;
return !!availableEngines.find(e => e.type === engineType)?.has_own_displays;
}
function _updateTestPickerLabels() {
const engineType = currentTestingTemplate?.engine_type;
const useDevice = _engineHasOwnDisplays(engineType);
const labelEl = document.getElementById('test-template-display-label');
const pickerLabel = document.getElementById('test-display-picker-label');
if (labelEl) {
labelEl.textContent = t(useDevice ? 'templates.test.device' : 'templates.test.display');
}
// Only swap placeholder text when nothing has been selected yet
if (pickerLabel) {
const currentValue = (document.getElementById('test-template-display') as HTMLInputElement | null)?.value;
if (!currentValue) {
pickerLabel.textContent = t(useDevice ? 'templates.test.device.select' : 'displays.picker.select');
}
}
}
export function openTestDisplayPicker() {
const engineType = currentTestingTemplate?.engine_type || null;
const pickerEngineType = _engineHasOwnDisplays(engineType) ? engineType : null;
const currentValue = (document.getElementById('test-template-display') as HTMLInputElement).value;
openDisplayPicker((window as any).onTestDisplaySelected, currentValue, pickerEngineType);
}
async function loadDisplaysForTest() {
try {
// Use engine-specific display list for engines with own devices (camera, scrcpy)
const engineType = currentTestingTemplate?.engine_type;
const engineHasOwnDisplays = availableEngines.find(e => e.type === engineType)?.has_own_displays || false;
const engineHasOwnDisplays = _engineHasOwnDisplays(engineType);
_updateTestPickerLabels();
const url = engineHasOwnDisplays
? `/config/displays?engine_type=${engineType}`
: '/config/displays';
@@ -389,7 +446,10 @@ export function runTemplateTest() {
const captureDuration = parseFloat((document.getElementById('test-template-duration') as HTMLInputElement).value);
if (displayIndex === '') {
showToast(t('templates.test.error.no_display'), 'error');
const errKey = _engineHasOwnDisplays(currentTestingTemplate.engine_type)
? 'templates.test.error.no_device'
: 'templates.test.error.no_display';
showToast(t(errKey), 'error');
return;
}
@@ -9,7 +9,7 @@ import {
_cachedPPTemplates,
_cachedCaptureTemplates,
_availableFilters,
availableEngines, setAvailableEngines,
availableEngines,
currentEditingTemplateId, setCurrentEditingTemplateId,
_templateNameManuallyEdited, set_templateNameManuallyEdited,
_streamNameManuallyEdited, set_streamNameManuallyEdited,
@@ -32,7 +32,7 @@ import {
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
_sourcesLoading, set_sourcesLoading,
apiKey,
streamsCache, ppTemplatesCache, captureTemplatesCache,
streamsCache, ppTemplatesCache, captureTemplatesCache, enginesCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, assetsCache, _cachedAssets, filtersCache,
colorStripSourcesCache,
csptCache, stripFiltersCache,
@@ -259,7 +259,7 @@ import { showAddTemplateModal as _localShowAddTemplateModal, _runTestViaWS as _l
export {
showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate,
showTestTemplateModal, closeTestTemplateModal, onEngineChange, runTemplateTest,
updateCaptureDuration, _runTestViaWS,
updateCaptureDuration, _runTestViaWS, openTestDisplayPicker,
} from './streams-capture-templates.ts';
// ── Audio Templates (extracted to streams-audio-templates.ts) ──
@@ -1166,7 +1166,9 @@ export async function editStream(streamId: any) {
await populateStreamModalDropdowns();
if (stream.stream_type === 'raw') {
(document.getElementById('stream-capture-template') as HTMLSelectElement).value = stream.capture_template_id || '';
const tplId = stream.capture_template_id || '';
(document.getElementById('stream-capture-template') as HTMLSelectElement).value = tplId;
if (_captureTemplateEntitySelect) _captureTemplateEntitySelect.setValue(tplId);
// Ensure correct engine displays are loaded for this template
await _onCaptureTemplateChanged();
const displayIdx = stream.display_index ?? 0;
@@ -1176,8 +1178,12 @@ export async function editStream(streamId: any) {
(document.getElementById('stream-target-fps') as HTMLInputElement).value = fps;
document.getElementById('stream-target-fps-value')!.textContent = fps;
} else if (stream.stream_type === 'processed') {
(document.getElementById('stream-source') as HTMLSelectElement).value = stream.source_stream_id || '';
(document.getElementById('stream-pp-template') as HTMLSelectElement).value = stream.postprocessing_template_id || '';
const srcId = stream.source_stream_id || '';
const ppId = stream.postprocessing_template_id || '';
(document.getElementById('stream-source') as HTMLSelectElement).value = srcId;
if (_sourceEntitySelect) _sourceEntitySelect.setValue(srcId);
(document.getElementById('stream-pp-template') as HTMLSelectElement).value = ppId;
if (_ppTemplateEntitySelect) _ppTemplateEntitySelect.setValue(ppId);
} else if (stream.stream_type === 'static_image') {
if (stream.image_asset_id) {
(document.getElementById('stream-image-asset') as HTMLSelectElement).value = stream.image_asset_id;
@@ -1227,6 +1233,7 @@ async function populateStreamModalDropdowns() {
streamsCache.fetch().catch((): any[] => []),
ppTemplatesCache.fetch().catch((): any[] => []),
displaysCache.fetch().catch((): any[] => []),
enginesCache.fetch().catch((): any[] => []),
]);
_streamModalDisplaysEngine = null;
@@ -523,26 +523,6 @@ function _formatPublished(iso: string | null | undefined): string | null {
function _renderReleaseNotesHeader(): void {
const release = _lastStatus?.release ?? null;
const name = release?.name?.trim() || '';
const version = release?.version?.trim() || '';
const nameEl = document.getElementById('release-notes-name');
if (nameEl) {
nameEl.textContent = name || t('update.release_notes');
}
// Only show the version accent when the name doesn't already contain
// the version — avoids rendering "LedGrab v0.5.0 v0.5.0".
const nameLower = name.toLowerCase();
const verLower = version.toLowerCase();
const nameHasVersion = !!verLower && (
nameLower.includes(verLower) || nameLower.includes('v' + verLower)
);
if (version && !nameHasVersion) {
_setRnText('release-notes-version', `v${version}`);
} else {
_setRnText('release-notes-version', null);
}
const tag = release?.tag?.trim() || null;
_setRnText('release-notes-tag', tag);
_setRnChipShown('release-notes-tag-chip', !!tag);
@@ -14,6 +14,7 @@ import {
_cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache,
_cachedHASources, _cachedColorStripSources, gradientsCache, GradientEntity,
_cachedGameIntegrations, _cachedGameAdapters, gameIntegrationsCache, gameAdaptersCache,
_cachedSyncClocks, syncClocksCache,
} from '../core/state.ts';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
@@ -24,7 +25,7 @@ import {
ICON_CLONE, ICON_EDIT, ICON_TEST,
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK,
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, ICON_TRASH,
ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS, ICON_GAMEPAD,
ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS, ICON_GAMEPAD, ICON_X,
} from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
@@ -52,6 +53,7 @@ let _vsCSSSourceEntitySelect: EntitySelect | null = null;
let _vsGradientEasingIconSelect: IconSelect | null = null;
let _vsBehaviorIconSelect: IconSelect | null = null;
let _vsMetricIconSelect: IconSelect | null = null;
let _vsAnimColorClockEntitySelect: EntitySelect | null = null;
let _vsTagsInput: TagInput | null = null;
class ValueSourceModal extends Modal {
@@ -68,6 +70,7 @@ class ValueSourceModal extends Modal {
if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; }
if (_vsBehaviorIconSelect) { _vsBehaviorIconSelect.destroy(); _vsBehaviorIconSelect = null; }
if (_vsMetricIconSelect) { _vsMetricIconSelect.destroy(); _vsMetricIconSelect = null; }
if (_vsAnimColorClockEntitySelect) { _vsAnimColorClockEntitySelect.destroy(); _vsAnimColorClockEntitySelect = null; }
if (_vsGameIntegrationEntitySelect) { _vsGameIntegrationEntitySelect.destroy(); _vsGameIntegrationEntitySelect = null; }
}
@@ -97,10 +100,12 @@ class ValueSourceModal extends Modal {
daylightSpeed: (document.getElementById('value-source-daylight-speed') as HTMLInputElement).value,
daylightRealTime: (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked,
daylightLatitude: (document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value,
daylightLongitude: (document.getElementById('value-source-daylight-longitude') as HTMLInputElement).value,
staticColor: (document.getElementById('value-source-static-color') as HTMLInputElement).value,
animatedColors: JSON.stringify(_animatedColors),
animatedColorSpeed: (document.getElementById('value-source-animated-color-speed') as HTMLInputElement).value,
animatedColorEasing: (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value,
animatedColorClock: (document.getElementById('value-source-animated-color-clock') as HTMLSelectElement)?.value || '',
colorSchedule: JSON.stringify(_colorSchedulePoints),
tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []),
metric: (document.getElementById('value-source-metric') as HTMLSelectElement).value,
@@ -200,11 +205,15 @@ function _ensureColorEasingIconSelect() {
const sel = document.getElementById('value-source-animated-color-easing') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'linear', icon: _icon(P.activity), label: t('value_source.animated_color.easing.linear'), desc: t('value_source.animated_color.easing.linear.desc') },
{ value: 'step', icon: _icon(P.layoutDashboard), label: t('value_source.animated_color.easing.step'), desc: t('value_source.animated_color.easing.step.desc') },
{ value: 'linear', icon: _icon(P.easingLinear), label: t('value_source.animated_color.easing.linear'), desc: t('value_source.animated_color.easing.linear.desc') },
{ value: 'step', icon: _icon(P.easingStep), label: t('value_source.animated_color.easing.step'), desc: t('value_source.animated_color.easing.step.desc') },
{ value: 'ease_in', icon: _icon(P.easingIn), label: t('value_source.animated_color.easing.ease_in'), desc: t('value_source.animated_color.easing.ease_in.desc') },
{ value: 'ease_out', icon: _icon(P.easingOut), label: t('value_source.animated_color.easing.ease_out'), desc: t('value_source.animated_color.easing.ease_out.desc') },
{ value: 'ease_in_out', icon: _icon(P.easingInOut), label: t('value_source.animated_color.easing.ease_in_out'), desc: t('value_source.animated_color.easing.ease_in_out.desc') },
{ value: 'sine', icon: _icon(P.easingSine), label: t('value_source.animated_color.easing.sine'), desc: t('value_source.animated_color.easing.sine.desc') },
];
if (_vsColorEasingIconSelect) { _vsColorEasingIconSelect.updateItems(items); return; }
_vsColorEasingIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
_vsColorEasingIconSelect = new IconSelect({ target: sel, items, columns: 3 } as any);
}
/* ── Waveform canvas preview ──────────────────────────────────── */
@@ -500,6 +509,7 @@ export async function showValueSourceModal(editData: any, presetType: any = null
_setSlider('value-source-daylight-speed', editData.speed ?? 1.0);
(document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked = !!editData.use_real_time;
_setSlider('value-source-daylight-latitude', editData.latitude ?? 50);
_setSlider('value-source-daylight-longitude', editData.longitude ?? 0);
_syncDaylightVSSpeedVisibility();
_setSlider('value-source-adaptive-min-value', editData.min_value ?? 0);
_setSlider('value-source-adaptive-max-value', editData.max_value ?? 1);
@@ -512,6 +522,8 @@ export async function showValueSourceModal(editData: any, presetType: any = null
_setSlider('value-source-animated-color-speed', editData.speed ?? 10.0);
(document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value = editData.easing || 'linear';
_ensureColorEasingIconSelect();
await syncClocksCache.fetch();
_populateAnimatedColorClockDropdown(editData.clock_id || '');
} else if (editData.source_type === 'adaptive_time_color') {
_colorSchedulePoints = (editData.schedule || []).map((p: any) => ({
time: p.time,
@@ -586,6 +598,7 @@ export async function showValueSourceModal(editData: any, presetType: any = null
_setSlider('value-source-daylight-speed', 1.0);
(document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked = false;
_setSlider('value-source-daylight-latitude', 50);
_setSlider('value-source-daylight-longitude', 0);
_syncDaylightVSSpeedVisibility();
// Color type defaults
(document.getElementById('value-source-static-color') as HTMLInputElement).value = '#ffffff';
@@ -593,6 +606,8 @@ export async function showValueSourceModal(editData: any, presetType: any = null
_renderAnimatedColorList();
_setSlider('value-source-animated-color-speed', 10.0);
(document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value = 'linear';
await syncClocksCache.fetch();
_populateAnimatedColorClockDropdown('');
_colorSchedulePoints = [];
_renderColorScheduleList();
// HA entity defaults
@@ -768,6 +783,7 @@ export async function saveValueSource() {
payload.speed = parseFloat((document.getElementById('value-source-daylight-speed') as HTMLInputElement).value);
payload.use_real_time = (document.getElementById('value-source-daylight-real-time') as HTMLInputElement).checked;
payload.latitude = parseFloat((document.getElementById('value-source-daylight-latitude') as HTMLInputElement).value);
payload.longitude = parseFloat((document.getElementById('value-source-daylight-longitude') as HTMLInputElement).value);
payload.min_value = parseFloat((document.getElementById('value-source-adaptive-min-value') as HTMLInputElement).value);
payload.max_value = parseFloat((document.getElementById('value-source-adaptive-max-value') as HTMLInputElement).value);
} else if (sourceType === 'static_color') {
@@ -776,6 +792,8 @@ export async function saveValueSource() {
payload.colors = _getAnimatedColorsPayload();
payload.speed = parseFloat((document.getElementById('value-source-animated-color-speed') as HTMLInputElement).value);
payload.easing = (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value;
// Send empty string to clear (resolve_ref pattern); a real ID otherwise.
payload.clock_id = (document.getElementById('value-source-animated-color-clock') as HTMLSelectElement).value || '';
} else if (sourceType === 'adaptive_time_color') {
payload.schedule = _getColorSchedulePayload();
} else if (sourceType === 'ha_entity') {
@@ -1575,20 +1593,36 @@ export function removeAnimatedColor(idx: number) {
function _renderAnimatedColorList() {
const list = document.getElementById('value-source-animated-color-list');
if (!list) return;
list.innerHTML = _animatedColors.map((c, i) => `
<div class="schedule-row" style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
const canRemove = _animatedColors.length > 1;
const chips = _animatedColors.map((c, i) => `
<div class="animated-color-chip" role="listitem" style="--chip-color:${c}">
<input type="color" class="animated-color-input" value="${c}" data-idx="${i}"
onchange="_animatedColors[${i}] = this.value">
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="removeAnimatedColor(${i})">${ICON_TRASH}</button>
aria-label="Color ${i + 1}">
${canRemove ? `
<button type="button" class="animated-color-remove"
onclick="removeAnimatedColor(${i})"
aria-label="Remove color ${i + 1}">${ICON_X}</button>
` : ''}
</div>
`).join('');
// Wire up color inputs to update state immutably
const addBtn = `
<button type="button" class="animated-color-add" onclick="addAnimatedColor()"
aria-label="Add color">
<span class="animated-color-add-glyph">+</span>
</button>
`;
list.innerHTML = chips + addBtn;
// Wire up color inputs to update state immutably and refresh the chip's CSS variable
list.querySelectorAll('.animated-color-input').forEach((el) => {
const input = el as HTMLInputElement;
const idx = parseInt(input.dataset.idx || '0', 10);
input.addEventListener('input', () => {
const chip = input.closest('.animated-color-chip') as HTMLElement | null;
const onChange = () => {
_animatedColors = _animatedColors.map((c, i) => i === idx ? input.value : c);
});
if (chip) chip.style.setProperty('--chip-color', input.value);
};
input.addEventListener('input', onChange);
input.addEventListener('change', onChange);
});
}
@@ -1596,6 +1630,28 @@ function _getAnimatedColorsPayload(): number[][] {
return _animatedColors.map(c => hexToRgbArray(c));
}
function _populateAnimatedColorClockDropdown(selectedId: string) {
const sel = document.getElementById('value-source-animated-color-clock') as HTMLSelectElement | null;
if (!sel) return;
sel.innerHTML = `<option value="">${t('common.none_own_speed')}</option>` +
_cachedSyncClocks.map(c => `<option value="${c.id}">${escapeHtml(c.name)} (${c.speed}x)</option>`).join('');
sel.value = selectedId || '';
if (_vsAnimColorClockEntitySelect) { _vsAnimColorClockEntitySelect.destroy(); _vsAnimColorClockEntitySelect = null; }
_vsAnimColorClockEntitySelect = new EntitySelect({
target: sel,
getItems: () => _cachedSyncClocks.map(c => ({
value: c.id,
label: c.name,
icon: ICON_CLOCK,
desc: `${c.speed}x`,
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('common.none_own_speed'),
});
}
// ── Color Schedule helpers ──────────────────────────────────
let _colorSchedulePoints: { time: string; color: string }[] = [];
+1 -2
View File
@@ -118,6 +118,7 @@ interface Window {
closeTestTemplateModal: (...args: any[]) => any;
onEngineChange: (...args: any[]) => any;
runTemplateTest: (...args: any[]) => any;
openTestDisplayPicker: (...args: any[]) => any;
updateCaptureDuration: (...args: any[]) => any;
showAddStreamModal: (...args: any[]) => any;
editStream: (...args: any[]) => any;
@@ -262,8 +263,6 @@ startTargetOverlay: (...args: any[]) => any;
onCSSClockChange: (...args: any[]) => any;
onAnimationTypeChange: (...args: any[]) => any;
onDaylightRealTimeChange: (...args: any[]) => any;
colorCycleAddColor: (...args: any[]) => any;
colorCycleRemoveColor: (...args: any[]) => any;
compositeAddLayer: (...args: any[]) => any;
compositeRemoveLayer: (...args: any[]) => any;
mappedAddZone: (...args: any[]) => any;
+6 -4
View File
@@ -135,7 +135,7 @@ export type OutputTarget = LedOutputTarget | HALightOutputTarget;
export type CSSSourceType =
| 'picture' | 'picture_advanced' | 'static' | 'gradient'
| 'color_cycle' | 'effect' | 'composite' | 'mapped'
| 'effect' | 'composite' | 'mapped'
| 'audio' | 'api_input' | 'notification' | 'daylight'
| 'candlelight' | 'processed' | 'weather' | 'key_colors'
| 'game_event' | 'math_wave';
@@ -225,9 +225,6 @@ export interface ColorStripSource {
// Gradient
stops?: ColorStop[];
// Color cycle
colors?: number[][];
// Effect
effect_type?: string;
palette?: string;
@@ -269,6 +266,7 @@ export interface ColorStripSource {
// Daylight
use_real_time?: boolean;
latitude?: number;
longitude?: number;
// Candlelight
num_candles?: number;
@@ -398,6 +396,7 @@ export interface DaylightValueSource extends ValueSourceBase {
speed: number;
use_real_time: boolean;
latitude: number;
longitude: number;
min_value: number;
max_value: number;
}
@@ -414,6 +413,7 @@ export interface AnimatedColorValueSource extends ValueSourceBase {
colors: number[][];
speed: number;
easing: string;
clock_id?: string;
}
export interface AdaptiveTimeColorValueSource extends ValueSourceBase {
@@ -653,6 +653,7 @@ export interface HomeAssistantConnectionStatus {
name: string;
connected: boolean;
entity_count: number;
host?: string;
}
export interface HomeAssistantStatusResponse {
@@ -834,6 +835,7 @@ export interface EngineInfo {
available: boolean;
has_own_displays?: boolean;
default_config?: Record<string, any>;
config_choices?: Record<string, string[]>;
}
// ── Display ───────────────────────────────────────────────────
+49 -14
View File
@@ -62,7 +62,14 @@
"displays.none": "No displays available",
"displays.failed": "Failed to load displays",
"displays.picker.title": "Select a Display",
"displays.picker.title.device": "Select a Device",
"displays.picker.title.lead": "Select",
"displays.picker.title.accent": "Display",
"displays.picker.eyebrow.label": "Source",
"displays.picker.eyebrow.channel": "Display · Map",
"displays.picker.eyebrow.channel.device": "Device · List",
"displays.picker.foot.dismiss": "dismiss",
"displays.picker.foot.select": "select monitor",
"displays.picker.foot.select.device": "select device",
"displays.picker.select": "Select display...",
"displays.picker.click_to_select": "Click to select this display",
"displays.picker.adb_connect": "Connect ADB device",
@@ -104,6 +111,12 @@
"templates.config.camera_backend.dshow": "Windows DirectShow",
"templates.config.camera_backend.msmf": "Windows Media Foundation",
"templates.config.camera_backend.v4l2": "Linux Video4Linux2",
"templates.config.resolution.auto": "Open at the camera's max supported mode",
"templates.config.resolution.480p": "VGA — lowest CPU and bandwidth",
"templates.config.resolution.720p": "HD",
"templates.config.resolution.1080p": "Full HD",
"templates.config.resolution.1440p": "QHD / 2K",
"templates.config.resolution.2160p": "4K UHD — highest CPU and bandwidth",
"templates.created": "Template created successfully",
"templates.updated": "Template updated successfully",
"templates.deleted": "Template deleted successfully",
@@ -116,6 +129,8 @@
"templates.test.description": "Test this template before saving to see a capture preview and performance metrics.",
"templates.test.display": "Display:",
"templates.test.display.select": "Select display...",
"templates.test.device": "Device:",
"templates.test.device.select": "Select device...",
"templates.test.duration": "Capture Duration (s):",
"templates.test.border_width": "Border Width (px):",
"templates.test.run": "Run",
@@ -138,6 +153,7 @@
"templates.test.results.resolution": "Resolution:",
"templates.test.error.no_engine": "Please select a capture engine",
"templates.test.error.no_display": "Please select a display",
"templates.test.error.no_device": "Please select a device",
"templates.test.error.failed": "Test failed",
"devices.title": "Devices",
"device.select_type": "Select Device Type",
@@ -1121,8 +1137,6 @@
"color_strip.type.static.desc": "Single solid color fill",
"color_strip.type.gradient": "Gradient",
"color_strip.type.gradient.desc": "Smooth color transition across LEDs",
"color_strip.type.color_cycle": "Color Cycle",
"color_strip.type.color_cycle.desc": "Cycle through a list of colors",
"color_strip.static_color": "Color:",
"color_strip.static_color.hint": "The solid color that will be sent to all LEDs on the strip.",
"color_strip.gradient.preview": "Gradient:",
@@ -1174,7 +1188,6 @@
"color_strip.animation.type.none.desc": "Static colors with no animation",
"color_strip.animation.type.breathing": "Breathing",
"color_strip.animation.type.breathing.desc": "Smooth brightness fade in and out",
"color_strip.animation.type.color_cycle": "Color Cycle",
"color_strip.animation.type.gradient_shift": "Gradient Shift",
"color_strip.animation.type.gradient_shift.desc": "Slides the gradient along the strip",
"color_strip.animation.type.wave": "Wave",
@@ -1195,12 +1208,6 @@
"color_strip.animation.type.hue_rotate.desc": "Smoothly rotates all pixel hues while preserving saturation and brightness",
"color_strip.animation.speed": "Speed:",
"color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.",
"color_strip.color_cycle.colors": "Colors:",
"color_strip.color_cycle.colors.hint": "List of colors to cycle through smoothly. At least 2 required. Default is a full rainbow spectrum.",
"color_strip.color_cycle.add_color": "+ Add Color",
"color_strip.color_cycle.speed": "Speed:",
"color_strip.color_cycle.speed.hint": "Cycle speed multiplier. 1.0 ≈ one full cycle every 20 seconds; higher values cycle faster.",
"color_strip.color_cycle.min_colors": "Color cycle must have at least 2 colors",
"color_strip.type.effect": "Effect",
"color_strip.type.effect.desc": "Procedural effects like fire, plasma, aurora",
"color_strip.type.effect.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.",
@@ -1636,7 +1643,7 @@
"value_source.type.adaptive_scene": "Adaptive (Scene)",
"value_source.type.adaptive_scene.desc": "Adjusts by scene content",
"value_source.type.daylight": "Daylight Cycle",
"value_source.type.daylight.desc": "Brightness follows day/night cycle",
"value_source.type.daylight.desc": "Value follows day/night cycle",
"value_source.type.static_color": "Static Color",
"value_source.type.static_color.desc": "Fixed RGB color",
"value_source.type.animated_color": "Animated Color",
@@ -1706,18 +1713,30 @@
"value_source.animated_color.speed": "Speed (cpm):",
"value_source.animated_color.easing": "Easing:",
"value_source.animated_color.easing.linear": "Linear",
"value_source.animated_color.easing.linear.desc": "Smooth blend between colors",
"value_source.animated_color.easing.linear.desc": "Constant rate blend between colors",
"value_source.animated_color.easing.step": "Step",
"value_source.animated_color.easing.step.desc": "Instant jump between colors",
"value_source.animated_color.easing.ease_in": "Ease In",
"value_source.animated_color.easing.ease_in.desc": "Slow start, accelerating into the next color",
"value_source.animated_color.easing.ease_out": "Ease Out",
"value_source.animated_color.easing.ease_out.desc": "Quick start, settling into the next color",
"value_source.animated_color.easing.ease_in_out": "Ease In Out",
"value_source.animated_color.easing.ease_in_out.desc": "Smooth at both ends, faster in the middle",
"value_source.animated_color.easing.sine": "Sine",
"value_source.animated_color.easing.sine.desc": "Sinusoidal curve — natural ambient cycling",
"value_source.animated_color.color_count": "colors",
"value_source.animated_color.clock": "Animation Clock:",
"value_source.animated_color.clock.hint": "Optional sync clock — when set, the clock controls timing (its speed multiplier scales the cpm above) and pause state. Leave empty to run on the value source's own speed.",
"value_source.adaptive_time_color.schedule": "Color Schedule:",
"value_source.daylight.speed": "Speed:",
"value_source.daylight.speed.hint": "Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.",
"value_source.daylight.use_real_time": "Use Real Time:",
"value_source.daylight.use_real_time.hint": "When enabled, brightness follows the actual time of day. Speed is ignored.",
"value_source.daylight.use_real_time.hint": "When enabled, the value follows the actual time of day. Speed is ignored.",
"value_source.daylight.enable_real_time": "Follow wall clock",
"value_source.daylight.latitude": "Latitude:",
"value_source.daylight.latitude.hint": "Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.",
"value_source.daylight.latitude.hint": "Your geographic latitude (-90 to 90). Steepens or flattens the sunrise/sunset edges of the cycle.",
"value_source.daylight.longitude": "Longitude:",
"value_source.daylight.longitude.hint": "Your geographic longitude (-180 to 180). Shifts solar noon for accurate sunrise/sunset alignment with your wall clock.",
"value_source.daylight.real_time": "Real-time",
"value_source.daylight.speed_label": "Speed",
"value_source.value": "Value:",
@@ -1864,6 +1883,22 @@
"settings.shutdown_action.opt.stop_desc": "Run the normal stop sequence (per-device auto-restore applies)",
"settings.shutdown_action.opt.nothing": "Nothing",
"settings.shutdown_action.opt.nothing_desc": "Leave lights showing the last frame",
"settings.daylight_timezone.label": "Daylight timezone",
"settings.daylight_timezone.hint": "IANA timezone every \"real-time\" daylight cycle reads its wall clock in. Empty (default) uses the server's system timezone.",
"settings.daylight_timezone.saved": "Daylight timezone saved",
"settings.daylight_timezone.save_error": "Failed to save daylight timezone",
"common.daylight_tz.system": "System default",
"common.daylight_tz.system_desc": "Server clock — no override",
"common.daylight_tz.detected": "Browser-detected",
"common.daylight_tz.detected_label": "Auto-detected",
"common.daylight_tz.search": "Search timezones, cities, regions…",
"common.daylight_tz.region.utc": "Universal",
"common.daylight_tz.region.europe": "Europe",
"common.daylight_tz.region.africa": "Africa",
"common.daylight_tz.region.me": "Middle East",
"common.daylight_tz.region.asia": "Asia",
"common.daylight_tz.region.pacific": "Pacific",
"common.daylight_tz.region.americas": "Americas",
"settings.auto_backup.label": "Auto-Backup",
"settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.",
"settings.auto_backup.enable": "Enable auto-backup",
+38 -13
View File
@@ -66,7 +66,14 @@
"displays.none": "Нет доступных дисплеев",
"displays.failed": "Не удалось загрузить дисплеи",
"displays.picker.title": "Выберите Дисплей",
"displays.picker.title.device": "Выберите Устройство",
"displays.picker.title.lead": "Выбрать",
"displays.picker.title.accent": "Дисплей",
"displays.picker.eyebrow.label": "Источник",
"displays.picker.eyebrow.channel": "Дисплей · Карта",
"displays.picker.eyebrow.channel.device": "Устройство · Список",
"displays.picker.foot.dismiss": "закрыть",
"displays.picker.foot.select": "выбрать монитор",
"displays.picker.foot.select.device": "выбрать устройство",
"displays.picker.select": "Выберите дисплей...",
"displays.picker.click_to_select": "Нажмите, чтобы выбрать этот дисплей",
"displays.picker.adb_connect": "Подключить ADB устройство",
@@ -108,6 +115,12 @@
"templates.config.camera_backend.dshow": "Windows DirectShow",
"templates.config.camera_backend.msmf": "Windows Media Foundation",
"templates.config.camera_backend.v4l2": "Linux Video4Linux2",
"templates.config.resolution.auto": "Открыть в максимальном поддерживаемом разрешении камеры",
"templates.config.resolution.480p": "VGA — минимум CPU и трафика",
"templates.config.resolution.720p": "HD",
"templates.config.resolution.1080p": "Full HD",
"templates.config.resolution.1440p": "QHD / 2K",
"templates.config.resolution.2160p": "4K UHD — максимум CPU и трафика",
"templates.created": "Шаблон успешно создан",
"templates.updated": "Шаблон успешно обновлён",
"templates.deleted": "Шаблон успешно удалён",
@@ -120,6 +133,8 @@
"templates.test.description": "Протестируйте этот шаблон перед сохранением, чтобы увидеть предпросмотр захвата и метрики производительности.",
"templates.test.display": "Дисплей:",
"templates.test.display.select": "Выберите дисплей...",
"templates.test.device": "Устройство:",
"templates.test.device.select": "Выберите устройство...",
"templates.test.duration": "Длительность Захвата (с):",
"templates.test.border_width": "Ширина Границы (px):",
"templates.test.run": "Запустить",
@@ -142,6 +157,7 @@
"templates.test.results.resolution": "Разрешение:",
"templates.test.error.no_engine": "Пожалуйста, выберите движок захвата",
"templates.test.error.no_display": "Пожалуйста, выберите дисплей",
"templates.test.error.no_device": "Пожалуйста, выберите устройство",
"templates.test.error.failed": "Тест не удался",
"devices.title": "Устройства",
"device.select_type": "Выберите тип устройства",
@@ -1102,8 +1118,6 @@
"color_strip.type.static.desc": "Заливка одним цветом",
"color_strip.type.gradient": "Градиент",
"color_strip.type.gradient.desc": "Плавный переход цветов по ленте",
"color_strip.type.color_cycle": "Смена цвета",
"color_strip.type.color_cycle.desc": "Циклическая смена списка цветов",
"color_strip.static_color": "Цвет:",
"color_strip.static_color.hint": "Статический цвет, который будет отправлен на все светодиоды полосы.",
"color_strip.gradient.preview": "Градиент:",
@@ -1142,7 +1156,6 @@
"color_strip.animation.type.none.desc": "Статичные цвета без анимации",
"color_strip.animation.type.breathing": "Дыхание",
"color_strip.animation.type.breathing.desc": "Плавное угасание и нарастание яркости",
"color_strip.animation.type.color_cycle": "Смена цвета",
"color_strip.animation.type.gradient_shift": "Сдвиг градиента",
"color_strip.animation.type.gradient_shift.desc": "Сдвигает градиент вдоль ленты",
"color_strip.animation.type.wave": "Волна",
@@ -1159,12 +1172,6 @@
"color_strip.animation.type.rainbow_fade.desc": "Циклический переход по всему спектру оттенков",
"color_strip.animation.speed": "Скорость:",
"color_strip.animation.speed.hint": "Множитель скорости анимации. 1.0 ≈ один цикл в секунду для дыхания; большие значения ускоряют анимацию.",
"color_strip.color_cycle.colors": "Цвета:",
"color_strip.color_cycle.colors.hint": "Список цветов для плавной циклической смены. Минимум 2 цвета. По умолчанию — полный радужный спектр.",
"color_strip.color_cycle.add_color": "+ Добавить цвет",
"color_strip.color_cycle.speed": "Скорость:",
"color_strip.color_cycle.speed.hint": "Множитель скорости смены. 1.0 ≈ один полный цикл за 20 секунд; большие значения ускоряют смену.",
"color_strip.color_cycle.min_colors": "Смена цвета должна содержать не менее 2 цветов",
"color_strip.type.effect": "Эффект",
"color_strip.type.effect.desc": "Процедурные эффекты: огонь, плазма, аврора",
"color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.",
@@ -1526,14 +1533,16 @@
"value_source.type.adaptive_scene": "Адаптивный (Сцена)",
"value_source.type.adaptive_scene.desc": "Подстройка по содержимому сцены",
"value_source.type.daylight": "Дневной цикл",
"value_source.type.daylight.desc": "Яркость следует за циклом дня/ночи",
"value_source.type.daylight.desc": "Значение следует циклу дня/ночи",
"value_source.daylight.speed": "Скорость:",
"value_source.daylight.speed.hint": "Множитель скорости цикла. 1.0 = полный цикл день/ночь за ~4 минуты.",
"value_source.daylight.use_real_time": "Реальное время:",
"value_source.daylight.use_real_time.hint": "Яркость следует за реальным временем суток. Скорость игнорируется.",
"value_source.daylight.use_real_time.hint": "Значение следует за реальным временем суток. Скорость игнорируется.",
"value_source.daylight.enable_real_time": "Следовать за часами",
"value_source.daylight.latitude": "Широта:",
"value_source.daylight.latitude.hint": "Географическая широта (-90 до 90). Влияет на время восхода/заката в режиме реального времени.",
"value_source.daylight.latitude.hint": "Географическая широта (-90 до 90). Делает переходы рассвета и заката круче или плавнее.",
"value_source.daylight.longitude": "Долгота:",
"value_source.daylight.longitude.hint": "Географическая долгота (-180 до 180). Сдвигает солнечный полдень для точного совпадения восхода и заката с настенными часами.",
"value_source.daylight.real_time": "Реальное время",
"value_source.daylight.speed_label": "Скорость",
"value_source.value": "Значение:",
@@ -1680,6 +1689,22 @@
"settings.shutdown_action.opt.stop_desc": "Обычная остановка (учитывается авто-восстановление устройств)",
"settings.shutdown_action.opt.nothing": "Ничего",
"settings.shutdown_action.opt.nothing_desc": "Оставить свет на последнем кадре",
"settings.daylight_timezone.label": "Часовой пояс дневного цикла",
"settings.daylight_timezone.hint": "IANA часовой пояс, в котором каждый дневной цикл «реального времени» читает настенные часы. Пусто (по умолчанию) — использовать системный часовой пояс сервера.",
"settings.daylight_timezone.saved": "Часовой пояс дневного цикла сохранён",
"settings.daylight_timezone.save_error": "Не удалось сохранить часовой пояс",
"common.daylight_tz.system": "Системный по умолчанию",
"common.daylight_tz.system_desc": "Часы сервера — без переопределения",
"common.daylight_tz.detected": "Определён браузером",
"common.daylight_tz.detected_label": "Авто-определение",
"common.daylight_tz.search": "Поиск по поясам, городам, регионам…",
"common.daylight_tz.region.utc": "Универсальное",
"common.daylight_tz.region.europe": "Европа",
"common.daylight_tz.region.africa": "Африка",
"common.daylight_tz.region.me": "Ближний Восток",
"common.daylight_tz.region.asia": "Азия",
"common.daylight_tz.region.pacific": "Тихий океан",
"common.daylight_tz.region.americas": "Америка",
"settings.auto_backup.label": "Авто-бэкап",
"settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.",
"settings.auto_backup.enable": "Включить авто-бэкап",
+38 -13
View File
@@ -66,7 +66,14 @@
"displays.none": "没有可用的显示器",
"displays.failed": "加载显示器失败",
"displays.picker.title": "选择显示器",
"displays.picker.title.device": "选择设备",
"displays.picker.title.lead": "选择",
"displays.picker.title.accent": "显示器",
"displays.picker.eyebrow.label": "来源",
"displays.picker.eyebrow.channel": "显示器 · 布局",
"displays.picker.eyebrow.channel.device": "设备 · 列表",
"displays.picker.foot.dismiss": "关闭",
"displays.picker.foot.select": "选择显示器",
"displays.picker.foot.select.device": "选择设备",
"displays.picker.select": "选择显示器...",
"displays.picker.click_to_select": "点击选择此显示器",
"displays.picker.adb_connect": "连接 ADB 设备",
@@ -108,6 +115,12 @@
"templates.config.camera_backend.dshow": "Windows DirectShow",
"templates.config.camera_backend.msmf": "Windows Media Foundation",
"templates.config.camera_backend.v4l2": "Linux Video4Linux2",
"templates.config.resolution.auto": "以摄像头支持的最高模式打开",
"templates.config.resolution.480p": "VGA — CPU 与带宽最低",
"templates.config.resolution.720p": "HD",
"templates.config.resolution.1080p": "Full HD",
"templates.config.resolution.1440p": "QHD / 2K",
"templates.config.resolution.2160p": "4K UHD — CPU 与带宽最高",
"templates.created": "模板创建成功",
"templates.updated": "模板更新成功",
"templates.deleted": "模板删除成功",
@@ -120,6 +133,8 @@
"templates.test.description": "保存前测试此模板,查看采集预览和性能指标。",
"templates.test.display": "显示器:",
"templates.test.display.select": "选择显示器...",
"templates.test.device": "设备:",
"templates.test.device.select": "选择设备...",
"templates.test.duration": "采集时长(秒):",
"templates.test.border_width": "边框宽度(像素):",
"templates.test.run": "运行",
@@ -142,6 +157,7 @@
"templates.test.results.resolution": "分辨率:",
"templates.test.error.no_engine": "请选择采集引擎",
"templates.test.error.no_display": "请选择显示器",
"templates.test.error.no_device": "请选择设备",
"templates.test.error.failed": "测试失败",
"devices.title": "设备",
"device.select_type": "选择设备类型",
@@ -1102,8 +1118,6 @@
"color_strip.type.static.desc": "单色填充",
"color_strip.type.gradient": "渐变",
"color_strip.type.gradient.desc": "LED上的平滑颜色过渡",
"color_strip.type.color_cycle": "颜色循环",
"color_strip.type.color_cycle.desc": "循环切换颜色列表",
"color_strip.static_color": "颜色:",
"color_strip.static_color.hint": "将发送到灯带上所有 LED 的纯色。",
"color_strip.gradient.preview": "渐变:",
@@ -1142,7 +1156,6 @@
"color_strip.animation.type.none.desc": "静态颜色,无动画",
"color_strip.animation.type.breathing": "呼吸",
"color_strip.animation.type.breathing.desc": "平滑的亮度渐入渐出",
"color_strip.animation.type.color_cycle": "颜色循环",
"color_strip.animation.type.gradient_shift": "渐变移动",
"color_strip.animation.type.gradient_shift.desc": "渐变沿灯带滑动",
"color_strip.animation.type.wave": "波浪",
@@ -1159,12 +1172,6 @@
"color_strip.animation.type.rainbow_fade.desc": "循环整个色相光谱",
"color_strip.animation.speed": "速度:",
"color_strip.animation.speed.hint": "动画速度倍数。1.0 ≈ 呼吸效果每秒一个循环;更高值循环更快。",
"color_strip.color_cycle.colors": "颜色:",
"color_strip.color_cycle.colors.hint": "平滑循环的颜色列表。至少需要 2 种。默认为全彩虹光谱。",
"color_strip.color_cycle.add_color": "+ 添加颜色",
"color_strip.color_cycle.speed": "速度:",
"color_strip.color_cycle.speed.hint": "循环速度倍数。1.0 ≈ 每 20 秒一个完整循环;更高值循环更快。",
"color_strip.color_cycle.min_colors": "颜色循环至少需要 2 种颜色",
"color_strip.type.effect": "效果",
"color_strip.type.effect.desc": "程序化效果:火焰、等离子、极光",
"color_strip.type.effect.hint": "实时生成的程序化 LED 效果(火焰、流星、等离子、噪声、极光)。",
@@ -1526,14 +1533,16 @@
"value_source.type.adaptive_scene": "自适应(场景)",
"value_source.type.adaptive_scene.desc": "按场景内容调节",
"value_source.type.daylight": "日光周期",
"value_source.type.daylight.desc": "亮度跟随夜周期",
"value_source.type.daylight.desc": "数值跟随夜周期",
"value_source.daylight.speed": "速度:",
"value_source.daylight.speed.hint": "周期速度倍率。1.0 = 完整日夜周期约4分钟。",
"value_source.daylight.use_real_time": "使用实时:",
"value_source.daylight.use_real_time.hint": "启用后,亮度跟随实际时间。速度设置将被忽略。",
"value_source.daylight.use_real_time.hint": "启用后,数值跟随实际时间。速度设置将被忽略。",
"value_source.daylight.enable_real_time": "跟随系统时钟",
"value_source.daylight.latitude": "纬度:",
"value_source.daylight.latitude.hint": "地理纬度(-90到90)。影响实时模式下的日出/日落时间。",
"value_source.daylight.latitude.hint": "地理纬度(-90到90)。使日出/日落过渡更陡峭或更平缓。",
"value_source.daylight.longitude": "经度:",
"value_source.daylight.longitude.hint": "地理经度(-180到180)。偏移正午时刻,使日出/日落与挂钟时间对齐。",
"value_source.daylight.real_time": "实时",
"value_source.daylight.speed_label": "速度",
"value_source.value": "值:",
@@ -1680,6 +1689,22 @@
"settings.shutdown_action.opt.stop_desc": "执行正常停止流程(按设备应用自动恢复)",
"settings.shutdown_action.opt.nothing": "无",
"settings.shutdown_action.opt.nothing_desc": "让灯保持最后一帧",
"settings.daylight_timezone.label": "日光周期时区",
"settings.daylight_timezone.hint": "所有“实时”日光周期读取挂钟时间所用的 IANA 时区。留空(默认)则使用服务器的系统时区。",
"settings.daylight_timezone.saved": "日光周期时区已保存",
"settings.daylight_timezone.save_error": "保存日光周期时区失败",
"common.daylight_tz.system": "系统默认",
"common.daylight_tz.system_desc": "服务器时钟 — 不覆盖",
"common.daylight_tz.detected": "浏览器检测",
"common.daylight_tz.detected_label": "自动检测",
"common.daylight_tz.search": "搜索时区、城市、地区…",
"common.daylight_tz.region.utc": "世界标准",
"common.daylight_tz.region.europe": "欧洲",
"common.daylight_tz.region.africa": "非洲",
"common.daylight_tz.region.me": "中东",
"common.daylight_tz.region.asia": "亚洲",
"common.daylight_tz.region.pacific": "太平洋",
"common.daylight_tz.region.americas": "美洲",
"settings.auto_backup.label": "自动备份",
"settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。",
"settings.auto_backup.enable": "启用自动备份",
+4 -2
View File
@@ -97,11 +97,13 @@ class CaptureAudioSource(AudioSource):
@classmethod
def from_dict(cls, data: dict) -> "CaptureAudioSource":
common = _parse_common_fields(data)
raw_device_index = data.get("device_index")
raw_is_loopback = data.get("is_loopback")
return cls(
**common,
source_type="capture",
device_index=int(data.get("device_index", -1)),
is_loopback=bool(data.get("is_loopback", True)),
device_index=int(raw_device_index) if raw_device_index is not None else -1,
is_loopback=bool(raw_is_loopback) if raw_is_loopback is not None else True,
audio_template_id=data.get("audio_template_id"),
)
@@ -9,7 +9,6 @@ Current types:
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
AudioColorStripSource audio-reactive visualization (spectrum, beat pulse, VU meter)
ApiInputColorStripSource receives raw LED colors from external clients via REST/WebSocket
NotificationColorStripSource fires one-shot visual alerts (flash, pulse, sweep) via API
@@ -69,7 +68,7 @@ class ColorStripSource:
def sharable(self) -> bool:
"""Whether multiple consumers can share a single stream instance.
Count-dependent sources (static, gradient, cycle, effect) return False
Count-dependent sources (static, gradient, effect) return False
because each consumer may configure a different LED count.
"""
return False
@@ -517,82 +516,6 @@ class GradientColorStripSource(ColorStripSource):
self.gradient_id = kwargs["gradient_id"]
@dataclass
class ColorCycleColorStripSource(ColorStripSource):
"""Color strip source that smoothly cycles through a user-defined list of colors.
All LEDs receive the same solid color at any point in time, smoothly
interpolating between the configured color stops in a continuous loop.
LED count auto-sizes from the connected device.
"""
colors: list = field(
default_factory=lambda: [
[255, 0, 0],
[255, 255, 0],
[0, 255, 0],
[0, 255, 255],
[0, 0, 255],
[255, 0, 255],
]
)
def to_dict(self) -> dict:
d = super().to_dict()
d["colors"] = [list(c) for c in self.colors]
return d
@classmethod
def from_dict(cls, data: dict) -> "ColorCycleColorStripSource":
common = _parse_css_common(data)
raw_colors = data.get("colors")
return cls(
**common,
source_type="color_cycle",
colors=raw_colors if isinstance(raw_colors, list) else [],
)
@classmethod
def create_from_kwargs(
cls,
*,
id: str,
name: str,
source_type: str,
created_at: datetime,
updated_at: datetime,
description=None,
clock_id=None,
tags=None,
colors=None,
**_kwargs,
):
default_colors = [
[255, 0, 0],
[255, 255, 0],
[0, 255, 0],
[0, 255, 255],
[0, 0, 255],
[255, 0, 255],
]
return cls(
id=id,
name=name,
source_type="color_cycle",
created_at=created_at,
updated_at=updated_at,
description=description,
clock_id=clock_id,
tags=tags or [],
colors=colors if isinstance(colors, list) and len(colors) >= 2 else default_colors,
)
def apply_update(self, **kwargs) -> None:
colors = kwargs.get("colors")
if colors is not None and isinstance(colors, list) and len(colors) >= 2:
self.colors = colors
@dataclass
class EffectColorStripSource(ColorStripSource):
"""Color strip source that runs a procedural LED effect.
@@ -1893,7 +1816,6 @@ _SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = {
"picture_advanced": AdvancedPictureColorStripSource,
"static": StaticColorStripSource,
"gradient": GradientColorStripSource,
"color_cycle": ColorCycleColorStripSource,
"effect": EffectColorStripSource,
"audio": AudioColorStripSource,
"composite": CompositeColorStripSource,
@@ -8,9 +8,11 @@ from ledgrab.storage.base_sqlite_store import BaseSqliteStore
from ledgrab.storage.database import Database
from ledgrab.storage.utils import resolve_ref
from ledgrab.storage.color_strip_source import (
AdvancedPictureColorStripSource,
ColorStripSource,
CompositeColorStripSource,
MappedColorStripSource,
PictureColorStripSource,
ProcessedColorStripSource,
)
from ledgrab.utils import get_logger
@@ -252,3 +254,24 @@ class ColorStripStore(BaseSqliteStore[ColorStripSource]):
if source.input_source_id == source_id:
names.append(source.name)
return names
def get_referencing_picture_source(self, picture_source_id: str) -> List[ColorStripSource]:
"""Return color strip sources that reference a given picture source.
Includes simple `PictureColorStripSource` (via `picture_source_id`) and
`AdvancedPictureColorStripSource` (via per-line calibration entries).
"""
result: List[ColorStripSource] = []
for source in self._items.values():
if (
isinstance(source, PictureColorStripSource)
and source.picture_source_id == picture_source_id
):
result.append(source)
elif isinstance(source, AdvancedPictureColorStripSource):
lines = getattr(source.calibration, "lines", None) or []
if any(
getattr(line, "picture_source_id", "") == picture_source_id for line in lines
):
result.append(source)
return result
@@ -305,9 +305,29 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
# ── Query helpers ─────────────────────────────────────────────────
def get_targets_referencing(self, stream_id: str, target_store) -> List[str]:
"""Return names of targets that reference this stream."""
return target_store.get_targets_referencing_source(stream_id)
def get_targets_referencing(self, stream_id: str, target_store, css_store=None) -> List[str]:
"""Return names of output targets that transitively reference this stream.
Output targets reference picture sources only via color strip sources, so
this walks: picture source CSS target. Returns deduped target names
in stable order. ``css_store`` is required for transitive resolution; if
omitted, returns an empty list (legacy callers without a CSS store).
"""
if css_store is None:
return []
css_list = css_store.get_referencing_picture_source(stream_id)
if not css_list:
return []
seen: Set[str] = set()
ordered: List[str] = []
for css in css_list:
for name in target_store.get_targets_referencing_css(css.id):
if name not in seen:
seen.add(name)
ordered.append(name)
return ordered
def resolve_stream_chain(self, stream_id: str) -> dict:
"""Resolve a stream chain to get the terminal stream and collected postprocessing templates.
@@ -63,6 +63,7 @@ class ValueSource:
"scene_behavior": None,
"use_real_time": None,
"latitude": None,
"longitude": None,
}
@staticmethod
@@ -254,6 +255,7 @@ class DaylightValueSource(ValueSource):
speed: float = 1.0 # simulation speed (ignored when use_real_time)
use_real_time: bool = False # use wall clock instead of simulation
latitude: float = 50.0 # affects sunrise/sunset in real-time mode
longitude: float = 0.0 # affects solar noon offset
min_value: float = 0.0 # output range min
max_value: float = 1.0 # output range max
@@ -262,6 +264,7 @@ class DaylightValueSource(ValueSource):
d["speed"] = self.speed
d["use_real_time"] = self.use_real_time
d["latitude"] = self.latitude
d["longitude"] = self.longitude
d["min_value"] = self.min_value
d["max_value"] = self.max_value
return d
@@ -275,6 +278,7 @@ class DaylightValueSource(ValueSource):
speed=float(data.get("speed") or 1.0),
use_real_time=bool(data.get("use_real_time", False)),
latitude=float(data.get("latitude") or 50.0),
longitude=float(data.get("longitude") or 0.0),
min_value=float(data.get("min_value") or 0.0),
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0,
)
@@ -312,12 +316,14 @@ class AnimatedColorValueSource(ValueSource):
colors: list = field(default_factory=lambda: [[255, 0, 0], [0, 255, 0], [0, 0, 255]])
speed: float = 10.0 # cycles per minute
easing: str = "linear" # linear | step
clock_id: Optional[str] = None # optional SyncClock reference for shared timing
def to_dict(self) -> dict:
d = super().to_dict()
d["colors"] = [list(c) for c in self.colors]
d["speed"] = self.speed
d["easing"] = self.easing
d["clock_id"] = self.clock_id
d["return_type"] = "color"
return d
@@ -336,6 +342,7 @@ class AnimatedColorValueSource(ValueSource):
colors=colors,
speed=float(data.get("speed") or 10.0),
easing=data.get("easing") or "linear",
clock_id=data.get("clock_id") or None,
)
@@ -64,6 +64,7 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
auto_gain: Optional[bool] = None,
use_real_time: Optional[bool] = None,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
color: Optional[list] = None,
colors: Optional[list] = None,
easing: Optional[str] = None,
@@ -83,6 +84,7 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
disk_path: Optional[str] = None,
sensor_label: Optional[str] = None,
poll_interval: Optional[float] = None,
clock_id: Optional[str] = None,
) -> ValueSource:
_VALID = (
"static",
@@ -195,6 +197,7 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
speed=speed if speed is not None else 1.0,
use_real_time=bool(use_real_time) if use_real_time is not None else False,
latitude=latitude if latitude is not None else 50.0,
longitude=longitude if longitude is not None else 0.0,
min_value=min_value if min_value is not None else 0.0,
max_value=max_value if max_value is not None else 1.0,
)
@@ -225,6 +228,7 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
),
speed=speed if speed is not None else 10.0,
easing=easing or "linear",
clock_id=clock_id or None,
)
elif source_type == "adaptive_time_color":
schedule_data = schedule or []
@@ -338,6 +342,7 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
auto_gain: Optional[bool] = None,
use_real_time: Optional[bool] = None,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
color: Optional[list] = None,
colors: Optional[list] = None,
easing: Optional[str] = None,
@@ -357,6 +362,7 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
disk_path: Optional[str] = None,
sensor_label: Optional[str] = None,
poll_interval: Optional[float] = None,
clock_id: Optional[str] = None,
) -> ValueSource:
source = self.get(source_id)
@@ -420,6 +426,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
source.use_real_time = use_real_time
if latitude is not None:
source.latitude = latitude
if longitude is not None:
source.longitude = longitude
if min_value is not None:
source.min_value = min_value
if max_value is not None:
@@ -434,6 +442,8 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
source.speed = speed
if easing is not None:
source.easing = easing
if clock_id is not None:
source.clock_id = resolve_ref(clock_id, source.clock_id)
elif isinstance(source, AdaptiveTimeColorValueSource):
if schedule is not None:
if len(schedule) < 2:
+4
View File
@@ -523,6 +523,10 @@
document.getElementById('targets-panel-content').innerHTML = loginMsg;
document.getElementById('streams-list').innerHTML = loginMsg;
document.getElementById('graph-editor-content').innerHTML = loginMsg;
// Re-open the login modal in forced mode so the user can sign back in
// without reloading. Mirrors the initial-load path in app.ts.
showApiKeyModal(null, true);
}
// Demo banner dismiss
@@ -45,7 +45,6 @@
<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>
<option value="effect" data-i18n="color_strip.type.effect">Procedural Effect</option>
<option value="composite" data-i18n="color_strip.type.composite">Composite</option>
<option value="mapped" data-i18n="color_strip.type.mapped">Mapped</option>
@@ -111,18 +110,6 @@
</div>
</div>
<!-- Color-cycle-specific fields -->
<div id="css-editor-color-cycle-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.color_cycle.colors">Colors:</label>
<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.color_cycle.colors.hint">Colors to cycle through smoothly. At least 2 required.</small>
<div id="color-cycle-colors-list"></div>
</div>
</div>
<!-- Gradient-specific fields -->
<div id="css-editor-gradient-section" style="display:none">
<div class="form-group">
@@ -542,15 +529,6 @@
<!-- Daylight Cycle section -->
<div id="css-editor-daylight-section" style="display:none">
<div id="css-editor-daylight-speed-group" class="form-group">
<div class="label-row">
<label for="css-editor-daylight-speed"><span data-i18n="color_strip.daylight.speed">Speed:</span> <span id="css-editor-daylight-speed-val">1.0</span></label>
<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.daylight.speed.hint">Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.</small>
<input type="range" id="css-editor-daylight-speed" min="0.1" max="10" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-daylight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-daylight-real-time" data-i18n="color_strip.daylight.use_real_time">Use Real Time:</label>
@@ -562,12 +540,21 @@
<span class="toggle-slider"></span>
</label>
</div>
<div id="css-editor-daylight-speed-group" class="form-group">
<div class="label-row">
<label for="css-editor-daylight-speed"><span data-i18n="color_strip.daylight.speed">Speed:</span> <span id="css-editor-daylight-speed-val">1.0</span></label>
<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.daylight.speed.hint">Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.</small>
<input type="range" id="css-editor-daylight-speed" min="0.1" max="10" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-daylight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-daylight-latitude"><span data-i18n="color_strip.daylight.latitude">Latitude:</span> <span id="css-editor-daylight-latitude-val">50</span>&deg;</label>
<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.daylight.latitude.hint">Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.</small>
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.latitude.hint">Your geographic latitude (-90 to 90). Steepens or flattens the sunrise/sunset edges of the cycle.</small>
<input type="range" id="css-editor-daylight-latitude" min="-90" max="90" step="1" value="50"
oninput="document.getElementById('css-editor-daylight-latitude-val').textContent = parseInt(this.value)">
</div>
@@ -576,7 +563,7 @@
<label for="css-editor-daylight-longitude"><span data-i18n="color_strip.daylight.longitude">Longitude:</span> <span id="css-editor-daylight-longitude-val">0</span>&deg;</label>
<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.daylight.longitude.hint">Your geographic longitude (-180 to 180). Adjusts solar noon offset for accurate sunrise/sunset timing.</small>
<small class="input-hint" style="display:none" data-i18n="color_strip.daylight.longitude.hint">Your geographic longitude (-180 to 180). Shifts solar noon for accurate sunrise/sunset alignment with your wall clock.</small>
<input type="range" id="css-editor-daylight-longitude" min="-180" max="180" step="1" value="0"
oninput="document.getElementById('css-editor-daylight-longitude-val').textContent = parseInt(this.value)">
</div>
@@ -832,7 +819,7 @@
<small id="css-editor-animation-type-desc" class="field-desc"></small>
</div>
<!-- Sync Clock (shown for animated types: static, gradient, color_cycle, effect) -->
<!-- Sync Clock (shown for animated types: static, gradient, effect) -->
<div id="css-editor-clock-group" class="form-group" style="display:none">
<div class="label-row">
<label for="css-editor-clock" data-i18n="color_strip.clock">Sync Clock:</label>
@@ -136,6 +136,17 @@
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="settings-daylight-timezone" data-i18n="settings.daylight_timezone.label">Daylight timezone</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.daylight_timezone.hint">IANA timezone every "real-time" daylight cycle reads its wall clock in. Empty (default) uses the server's system timezone.</small>
<div class="tz-picker-wrap">
<select id="settings-daylight-timezone"></select>
</div>
</div>
<div class="save-bar" id="settings-external-url-save-bar" hidden>
<div class="save-bar-msg">
<span data-i18n="settings.save_bar.unsaved">Unsaved changes in</span>
@@ -618,10 +629,6 @@
<span class="rn-eyebrow__sep" aria-hidden="true"></span>
<span class="rn-eyebrow__channel" id="release-notes-channel">CHANGELOG</span>
</div>
<h2 class="rn-title">
<span class="rn-title__main" id="release-notes-name">Release Notes</span>
<em class="rn-title__accent" id="release-notes-version" hidden></em>
</h2>
<div class="rn-meta" id="release-notes-meta" hidden>
<span class="rn-chip" id="release-notes-tag-chip" hidden>
<span class="rn-chip__dot" aria-hidden="true"></span>
@@ -15,9 +15,9 @@
</div>
<div class="ds-section-body">
<div class="form-group">
<label data-i18n="templates.test.display">Display:</label>
<label id="test-template-display-label" data-i18n="templates.test.display">Display:</label>
<input type="hidden" id="test-template-display" value="">
<button type="button" class="btn btn-display-picker" id="test-display-picker-btn" onclick="openDisplayPicker(onTestDisplaySelected, document.getElementById('test-template-display').value, window._engineHasOwnDisplays?.(window.currentTestingTemplate?.engine_type) ? window.currentTestingTemplate.engine_type : null)">
<button type="button" class="btn btn-display-picker" id="test-display-picker-btn" onclick="openTestDisplayPicker()">
<span id="test-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
</button>
</div>
@@ -257,6 +257,18 @@
<!-- Daylight fields -->
<div id="value-source-daylight-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="value-source-daylight-real-time" data-i18n="value_source.daylight.use_real_time">Use Real Time:</label>
<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="value_source.daylight.use_real_time.hint">When enabled, the value follows the actual time of day. Speed is ignored.</small>
<label class="toggle-switch">
<input type="checkbox" id="value-source-daylight-real-time" onchange="onDaylightVSRealTimeChange()">
<span class="toggle-slider"></span>
</label>
</div>
<div id="value-source-daylight-speed-group" class="form-group">
<div class="label-row">
<label for="value-source-daylight-speed"><span data-i18n="value_source.daylight.speed">Speed:</span> <span id="value-source-daylight-speed-display">1.0</span></label>
@@ -269,24 +281,22 @@
<div class="form-group">
<div class="label-row">
<label for="value-source-daylight-real-time" data-i18n="value_source.daylight.use_real_time">Use Real Time:</label>
<label for="value-source-daylight-latitude"><span data-i18n="value_source.daylight.latitude">Latitude:</span> <span id="value-source-daylight-latitude-display">50</span>&deg;</label>
<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="value_source.daylight.use_real_time.hint">When enabled, brightness follows the actual time of day. Speed is ignored.</small>
<label class="toggle-label">
<input type="checkbox" id="value-source-daylight-real-time" onchange="onDaylightVSRealTimeChange()">
<span data-i18n="value_source.daylight.enable_real_time">Follow wall clock</span>
</label>
<small class="input-hint" style="display:none" data-i18n="value_source.daylight.latitude.hint">Your geographic latitude (-90 to 90). Steepens or flattens the sunrise/sunset edges of the cycle.</small>
<input type="range" id="value-source-daylight-latitude" min="-90" max="90" step="1" value="50"
oninput="document.getElementById('value-source-daylight-latitude-display').textContent = parseInt(this.value)">
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-daylight-latitude"><span data-i18n="value_source.daylight.latitude">Latitude:</span> <span id="value-source-daylight-latitude-display">50</span>&deg;</label>
<label for="value-source-daylight-longitude"><span data-i18n="value_source.daylight.longitude">Longitude:</span> <span id="value-source-daylight-longitude-display">0</span>&deg;</label>
<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="value_source.daylight.latitude.hint">Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.</small>
<input type="range" id="value-source-daylight-latitude" min="-90" max="90" step="1" value="50"
oninput="document.getElementById('value-source-daylight-latitude-display').textContent = parseInt(this.value)">
<small class="input-hint" style="display:none" data-i18n="value_source.daylight.longitude.hint">Your geographic longitude (-180 to 180). Shifts solar noon for accurate sunrise/sunset alignment with your wall clock.</small>
<input type="range" id="value-source-daylight-longitude" min="-180" max="180" step="1" value="0"
oninput="document.getElementById('value-source-daylight-longitude-display').textContent = parseInt(this.value)">
</div>
</div>
@@ -306,8 +316,7 @@
<div class="label-row">
<label data-i18n="value_source.animated_color.colors">Colors:</label>
</div>
<div id="value-source-animated-color-list"></div>
<button type="button" class="btn btn-sm" onclick="addAnimatedColor()">+ Add Color</button>
<div id="value-source-animated-color-list" class="animated-color-row" role="list"></div>
</div>
<div class="form-group">
<div class="label-row">
@@ -326,8 +335,20 @@
<select id="value-source-animated-color-easing">
<option value="linear">Linear</option>
<option value="step">Step</option>
<option value="ease_in">Ease In</option>
<option value="ease_out">Ease Out</option>
<option value="ease_in_out">Ease In Out</option>
<option value="sine">Sine</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-animated-color-clock" data-i18n="value_source.animated_color.clock">Animation Clock:</label>
<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="value_source.animated_color.clock.hint">Optional sync clock — when set, the clock controls timing (its speed multiplier scales the cpm above) and pause state. Leave empty to run on the value source's own speed.</small>
<select id="value-source-animated-color-clock"></select>
</div>
</div>
<!-- Adaptive Time Color fields -->
@@ -1,10 +1,46 @@
<!-- Display Picker Lightbox -->
<div id="display-picker-lightbox" class="lightbox" onclick="closeDisplayPicker(event)">
<button class="lightbox-close" onclick="closeDisplayPicker()" title="Close">&#x2715;</button>
<!--
Display Picker Lightbox — Lumenworks studio-console shell.
Channel: cyan (sources / screen). Re-uses the .lightbox shell tokens
in modal.css; .lightbox-bracket-br paints the second silkscreen
corner (top-left bracket comes from .lightbox-content::after).
The header rack (eyebrow + display title) and the optional foot key
hint are scoped to .display-picker-content so the image lightbox
keeps its minimal, image-first layout.
Title is split into a static lead word ("Select") and a channel-tinted
accent ("Display" / "Device"); displays.ts swaps the accent + eyebrow
channel label depending on whether we are picking a monitor or an
engine-owned device (camera, scrcpy).
-->
<div id="display-picker-lightbox" class="lightbox" data-ch="cyan" onclick="closeDisplayPicker(event)">
<div class="lightbox-content display-picker-content">
<h3 class="display-picker-title" data-i18n="displays.picker.title">Select a Display</h3>
<span class="lightbox-bracket-br" aria-hidden="true"></span>
<button class="lightbox-close" type="button" onclick="closeDisplayPicker()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
<header class="display-picker-head">
<div class="display-picker-lede">
<div class="display-picker-eyebrow" aria-hidden="true">
<span class="display-picker-eyebrow__dot"></span>
<span data-role="label" data-i18n="displays.picker.eyebrow.label">Source</span>
<span class="display-picker-eyebrow__sep"></span>
<span class="display-picker-eyebrow__channel" data-i18n="displays.picker.eyebrow.channel">Display · Map</span>
</div>
<h2 class="display-picker-title" id="display-picker-title">
<span data-role="lead" data-i18n="displays.picker.title.lead">Select</span>
<em class="display-picker-title__accent" data-i18n="displays.picker.title.accent">Display</em>
</h2>
</div>
</header>
<div id="display-picker-canvas" class="display-picker-canvas">
<div class="loading-spinner"></div>
</div>
<footer class="display-picker-foot" aria-hidden="true">
<span><kbd>esc</kbd><span data-i18n="displays.picker.foot.dismiss">dismiss</span></span>
<span><kbd>click</kbd><span data-role="select" data-i18n="displays.picker.foot.select">select monitor</span></span>
</footer>
</div>
</div>
@@ -1,7 +1,14 @@
<!-- Image Lightbox -->
<div id="image-lightbox" class="lightbox" onclick="closeLightbox(event)">
<button class="lightbox-close" onclick="closeLightbox()" title="Close">&#x2715;</button>
<!--
Image Lightbox — Lumenworks studio-console shell, minimal variant.
Channel: cyan (preview / screen-capture). The shell is shared with
the display picker (see modal.css). Image is the hero so we skip the
big head and only keep the close button + readout strip.
-->
<div id="image-lightbox" class="lightbox" data-ch="cyan" onclick="closeLightbox(event)">
<div class="lightbox-content">
<span class="lightbox-bracket-br" aria-hidden="true"></span>
<button class="lightbox-close" type="button" onclick="closeLightbox()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
<img id="lightbox-image" src="" alt="Full size preview">
<div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div>
</div>
-16
View File
@@ -135,22 +135,6 @@ class TestColorStripSourceLifecycle:
resp = client.post("/api/v1/color-strip-sources", json=payload)
assert resp.status_code == 400 # duplicate name
def test_color_cycle_source(self, client):
"""Color cycle sources store and return their color list."""
resp = client.post(
"/api/v1/color-strip-sources",
json={
"name": "Rainbow Cycle",
"source_type": "color_cycle",
"colors": [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
"led_count": 30,
},
)
assert resp.status_code == 201
data = resp.json()
assert data["source_type"] == "color_cycle"
assert data["colors"] == [[255, 0, 0], [0, 255, 0], [0, 0, 255]]
def test_effect_source(self, client):
"""Effect sources store their effect parameters."""
resp = client.post(
+303
View File
@@ -0,0 +1,303 @@
"""Tests for the camera capture engine — focus on resolution selection logic.
The probe-for-max behavior in ``_enumerate_cameras`` and the resolution
priority logic in ``CameraCaptureStream.initialize`` are exercised here
without requiring a real webcam, by stubbing ``cv2.VideoCapture``.
"""
from __future__ import annotations
import sys
import types
from typing import Any, Dict, List, Tuple
import pytest
from ledgrab.core.capture_engines import camera_engine as ce
# ---------------------------------------------------------------------------
# _parse_resolution — pure function, no stubs needed
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"value,expected",
[
("auto", None),
("AUTO", None),
("", None),
(None, None),
(0, None),
("1920x1080", (1920, 1080)),
("1920X1080", (1920, 1080)),
("2560×1440", (2560, 1440)), # unicode multiplication sign
(" 1280x720 ", (1280, 720)),
("garbage", None),
("1920x", None),
("0x0", None),
("-1x100", None),
],
)
def test_parse_resolution(value: Any, expected: Tuple[int, int] | None) -> None:
assert ce._parse_resolution(value) == expected
# ---------------------------------------------------------------------------
# Stubs that emulate cv2.VideoCapture without needing a real camera
# ---------------------------------------------------------------------------
CAP_PROP_FRAME_WIDTH = 3
CAP_PROP_FRAME_HEIGHT = 4
CAP_PROP_FPS = 5
class _StubCap:
"""Minimal cv2.VideoCapture stand-in.
- ``default_dims``: what get() returns immediately after open.
- ``max_dims``: what set(9999) clamps to (the camera's hardware ceiling).
- ``set()`` honors any width/height up to ``max_dims`` and clamps higher.
"""
def __init__(
self,
default_dims: Tuple[int, int],
max_dims: Tuple[int, int],
opened: bool = True,
):
self._default = default_dims
self._max = max_dims
self._w, self._h = default_dims
self._opened = opened
self.read_calls = 0
self.set_calls: List[Tuple[int, float]] = []
def isOpened(self) -> bool:
return self._opened
def get(self, prop: int) -> float:
if prop == CAP_PROP_FRAME_WIDTH:
return float(self._w)
if prop == CAP_PROP_FRAME_HEIGHT:
return float(self._h)
if prop == CAP_PROP_FPS:
return 30.0
return 0.0
def set(self, prop: int, value: float) -> bool:
self.set_calls.append((prop, value))
if prop == CAP_PROP_FRAME_WIDTH:
self._w = int(min(value, self._max[0]))
elif prop == CAP_PROP_FRAME_HEIGHT:
self._h = int(min(value, self._max[1]))
return True
def read(self):
self.read_calls += 1
# Return a 3-channel frame of the current size as a list of zeros —
# the engine only inspects shape[:2].
import numpy as np
return True, np.zeros((self._h, self._w, 3), dtype=np.uint8)
def release(self) -> None:
self._opened = False
def _install_cv2_stub(monkeypatch: pytest.MonkeyPatch, factory) -> List[_StubCap]:
"""Install a fake `cv2` module so the engine code can `import cv2`.
`factory(index, backend) -> _StubCap` produces a stub for each open call.
Returns the list that all created stubs are appended to (for assertions).
"""
created: List[_StubCap] = []
def _video_capture(index, backend=None):
cap = factory(index, backend)
created.append(cap)
return cap
fake_cv2 = types.SimpleNamespace(
VideoCapture=_video_capture,
CAP_PROP_FRAME_WIDTH=CAP_PROP_FRAME_WIDTH,
CAP_PROP_FRAME_HEIGHT=CAP_PROP_FRAME_HEIGHT,
CAP_PROP_FPS=CAP_PROP_FPS,
COLOR_BGR2RGB=4,
cvtColor=lambda frame, _flag: frame, # passthrough
)
monkeypatch.setitem(sys.modules, "cv2", fake_cv2)
# Also bypass the SetupAPI friendly-name probe (Windows-only ctypes call)
monkeypatch.setattr(ce, "_get_camera_friendly_names", lambda: {0: "Stub Cam"})
# Reset the enumeration cache so each test sees a fresh probe
ce._camera_cache = None
ce._camera_cache_time = 0
ce._active_cv2_indices.clear()
return created
# ---------------------------------------------------------------------------
# _enumerate_cameras — probe-for-max
# ---------------------------------------------------------------------------
def test_enumerate_reports_camera_max_not_default(monkeypatch: pytest.MonkeyPatch) -> None:
"""A camera whose default mode is 640x480 but max is 2560x1440 should be
reported at its max that's what shows up in the display selector."""
def factory(index, _backend):
if index == 0:
return _StubCap(default_dims=(640, 480), max_dims=(2560, 1440))
return _StubCap(default_dims=(0, 0), max_dims=(0, 0), opened=False)
_install_cv2_stub(monkeypatch, factory)
cams = ce._enumerate_cameras("auto")
assert len(cams) == 1
assert cams[0]["width"] == 2560
assert cams[0]["height"] == 1440
assert cams[0]["name"] == "Stub Cam"
def test_enumerate_falls_back_when_probe_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
"""If a driver rejects the probe set() with an exception, fall back to the
default mode rather than reporting 0x0."""
class _RejectingCap(_StubCap):
def set(self, prop: int, value: float) -> bool:
raise RuntimeError("driver hates large values")
def factory(index, _backend):
if index == 0:
return _RejectingCap(default_dims=(800, 600), max_dims=(800, 600))
return _StubCap(default_dims=(0, 0), max_dims=(0, 0), opened=False)
_install_cv2_stub(monkeypatch, factory)
cams = ce._enumerate_cameras("auto")
assert len(cams) == 1
assert cams[0]["width"] == 800
assert cams[0]["height"] == 600
# ---------------------------------------------------------------------------
# CameraCaptureStream.initialize — resolution selection priority
# ---------------------------------------------------------------------------
def _open_stream_with_config(
monkeypatch: pytest.MonkeyPatch,
config: Dict[str, Any],
cam_max: Tuple[int, int] = (2560, 1440),
) -> Tuple[ce.CameraCaptureStream, List[_StubCap]]:
def factory(index, _backend):
if index == 0:
return _StubCap(default_dims=(640, 480), max_dims=cam_max)
return _StubCap(default_dims=(0, 0), max_dims=(0, 0), opened=False)
created = _install_cv2_stub(monkeypatch, factory)
stream = ce.CameraCaptureStream(display_index=0, config=config)
stream.initialize()
return stream, created
def _set_calls_for_size(cap: _StubCap) -> List[Tuple[int, float]]:
return [c for c in cap.set_calls if c[0] in (CAP_PROP_FRAME_WIDTH, CAP_PROP_FRAME_HEIGHT)]
def test_init_default_auto_opens_at_max(monkeypatch: pytest.MonkeyPatch) -> None:
"""No resolution config = open at camera max via _PROBE_MAX_DIM."""
stream, created = _open_stream_with_config(monkeypatch, config={})
# Two caps: one for enumeration probe, one for the stream's own open.
assert len(created) >= 2
stream_cap = created[-1]
size_sets = _set_calls_for_size(stream_cap)
# Stream should request the max-probe sentinel for both width and height.
assert (CAP_PROP_FRAME_WIDTH, float(ce._PROBE_MAX_DIM)) in size_sets
assert (CAP_PROP_FRAME_HEIGHT, float(ce._PROBE_MAX_DIM)) in size_sets
def test_init_resolution_auto_opens_at_max(monkeypatch: pytest.MonkeyPatch) -> None:
stream, created = _open_stream_with_config(monkeypatch, config={"resolution": "auto"})
stream_cap = created[-1]
size_sets = _set_calls_for_size(stream_cap)
assert (CAP_PROP_FRAME_WIDTH, float(ce._PROBE_MAX_DIM)) in size_sets
assert (CAP_PROP_FRAME_HEIGHT, float(ce._PROBE_MAX_DIM)) in size_sets
def test_init_explicit_resolution_string(monkeypatch: pytest.MonkeyPatch) -> None:
stream, created = _open_stream_with_config(monkeypatch, config={"resolution": "1280x720"})
stream_cap = created[-1]
size_sets = _set_calls_for_size(stream_cap)
assert (CAP_PROP_FRAME_WIDTH, 1280.0) in size_sets
assert (CAP_PROP_FRAME_HEIGHT, 720.0) in size_sets
# Should not request the max sentinel when an explicit size is given.
assert (CAP_PROP_FRAME_WIDTH, float(ce._PROBE_MAX_DIM)) not in size_sets
def test_init_legacy_numeric_keys_take_precedence(monkeypatch: pytest.MonkeyPatch) -> None:
"""Stored configs from before the rename still work and override the
new `resolution` field."""
stream, created = _open_stream_with_config(
monkeypatch,
config={
"resolution": "1920x1080",
"resolution_width": 1280,
"resolution_height": 720,
},
)
stream_cap = created[-1]
size_sets = _set_calls_for_size(stream_cap)
assert (CAP_PROP_FRAME_WIDTH, 1280.0) in size_sets
assert (CAP_PROP_FRAME_HEIGHT, 720.0) in size_sets
# `resolution` should be ignored when legacy numeric keys are set.
assert (CAP_PROP_FRAME_WIDTH, 1920.0) not in size_sets
def test_init_legacy_zeroes_fall_through_to_resolution(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Legacy keys with value 0 are the historical 'no override' state — they
should fall through to the new `resolution` field."""
stream, created = _open_stream_with_config(
monkeypatch,
config={
"resolution": "1280x720",
"resolution_width": 0,
"resolution_height": 0,
},
)
stream_cap = created[-1]
size_sets = _set_calls_for_size(stream_cap)
assert (CAP_PROP_FRAME_WIDTH, 1280.0) in size_sets
assert (CAP_PROP_FRAME_HEIGHT, 720.0) in size_sets
# ---------------------------------------------------------------------------
# Engine class surface — config defaults and choices
# ---------------------------------------------------------------------------
def test_default_config_exposes_resolution() -> None:
cfg = ce.CameraEngine.get_default_config()
assert cfg["resolution"] == "auto"
# Legacy numeric keys are no longer surfaced — UI shouldn't render them.
assert "resolution_width" not in cfg
assert "resolution_height" not in cfg
def test_config_choices_include_resolution_presets() -> None:
choices = ce.CameraEngine.get_config_choices()
assert "resolution" in choices
# Exact set: auto + the 5 standard sizes the UI lists.
assert choices["resolution"] == [
"auto",
"640x480",
"1280x720",
"1920x1080",
"2560x1440",
"3840x2160",
]