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:
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
attempts = 3 if backend_name == "msmf" else 1
|
||||
self._cap = None
|
||||
for attempt in range(attempts):
|
||||
if backend_id is not None:
|
||||
self._cap = cv2.VideoCapture(cv2_index, backend_id)
|
||||
cap = cv2.VideoCapture(cv2_index, backend_id)
|
||||
else:
|
||||
self._cap = cv2.VideoCapture(cv2_index)
|
||||
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
|
||||
|
||||
@@ -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 (0–24) → 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:
|
||||
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
|
||||
n = len(self._colors)
|
||||
if self._easing == "step":
|
||||
idx = int((elapsed / cycle_time * n) % n)
|
||||
return self._colors[idx]
|
||||
phase = (elapsed / cycle_time * n) % n
|
||||
self._last_phase = phase
|
||||
|
||||
if self._easing == "step":
|
||||
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
@@ -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 === '&' ? '&'
|
||||
: c === '<' ? '<'
|
||||
: c === '>' ? '>'
|
||||
: c === '"' ? '"'
|
||||
: ''',
|
||||
);
|
||||
}
|
||||
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"/>';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,24 +25,53 @@ 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)
|
||||
// Kick off async fetches after activation; spinner is already in place.
|
||||
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>';
|
||||
} else if (!_cachedDisplays || _cachedDisplays.length === 0) {
|
||||
displaysCache.fetch().then(displays => {
|
||||
if (displays && displays.length > 0) {
|
||||
renderDisplayPickerLayout(displays);
|
||||
@@ -51,7 +80,6 @@ export function openDisplayPicker(callback: (index: number, display?: Display |
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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
@@ -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;
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Включить авто-бэкап",
|
||||
|
||||
@@ -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": "启用自动备份",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>°</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>°</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>°</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>°</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>°</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">✕</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">✕</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">✕</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">✕</button>
|
||||
<img id="lightbox-image" src="" alt="Full size preview">
|
||||
<div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user