feat(scenes): scene playlists with timed auto-cycling

Add ordered, timed sequences of scene presets that auto-cycle — activating
each preset and holding it for its dwell duration before advancing.

Backend:
- ScenePlaylist / PlaylistItem models + SQLite store (new scene_playlists table)
- PlaylistEngine: cycles ONE playlist at a time (starting one stops any other),
  loop/shuffle, re-reads the playlist each cycle so edits/deletes apply at the
  boundary, skips missing presets, guards against busy-loops; reuses the shared
  apply_scene_state path used by scene presets and automations
- REST API: CRUD + /start, /stop, /state with scene-preset reference validation
- Constructed in the app lifespan with a bounded stop on shutdown

Frontend:
- New "Playlists" sub-tab in the Automations tab with start/stop controls and a
  running indicator; editor modal with ordered scene rows (reorder + per-item
  duration), loop/shuffle toggles, and tags
- Live refresh via the playlist_state_changed WebSocket event
- i18n in en/ru/zh

Tests: new unit + API coverage for the store/model, engine (cycling,
single-active exclusivity, missing-preset skip, shuffle, and the
playlist_state_changed event contract), and routes. Full suite green;
ruff and tsc clean.
This commit is contained in:
2026-06-08 13:48:43 +03:00
parent ca59546711
commit f71e10ee06
27 changed files with 2739 additions and 6 deletions
+2
View File
@@ -18,6 +18,7 @@ from .routes.audio_templates import router as audio_templates_router
from .routes.value_sources import router as value_sources_router
from .routes.automations import router as automations_router
from .routes.scene_presets import router as scene_presets_router
from .routes.scene_playlists import router as scene_playlists_router
from .routes.webhooks import router as webhooks_router
from .routes.sync_clocks import router as sync_clocks_router
from .routes.color_strip_processing import router as cspt_router
@@ -53,6 +54,7 @@ router.include_router(output_targets_router)
router.include_router(output_targets_control_router)
router.include_router(automations_router)
router.include_router(scene_presets_router)
router.include_router(scene_playlists_router)
router.include_router(webhooks_router)
router.include_router(sync_clocks_router)
router.include_router(cspt_router)
+14
View File
@@ -19,6 +19,7 @@ from ledgrab.storage.audio_template_store import AudioTemplateStore
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
from ledgrab.storage.sync_clock_store import SyncClockStore
from ledgrab.storage.color_strip_processing_template_store import (
ColorStripProcessingTemplateStore,
@@ -27,6 +28,7 @@ from ledgrab.storage.gradient_store import GradientStore
from ledgrab.storage.weather_source_store import WeatherSourceStore
from ledgrab.storage.asset_store import AssetStore
from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
from ledgrab.core.weather.weather_manager import WeatherManager
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
@@ -110,6 +112,14 @@ def get_automation_engine() -> AutomationEngine:
return _get("automation_engine", "Automation engine")
def get_scene_playlist_store() -> ScenePlaylistStore:
return _get("scene_playlist_store", "Scene playlist store")
def get_playlist_engine() -> PlaylistEngine:
return _get("playlist_engine", "Playlist engine")
def get_auto_backup_engine() -> AutoBackupEngine:
return _get("auto_backup_engine", "Auto-backup engine")
@@ -226,7 +236,9 @@ def init_dependencies(
value_source_store: ValueSourceStore | None = None,
automation_store: AutomationStore | None = None,
scene_preset_store: ScenePresetStore | None = None,
scene_playlist_store: ScenePlaylistStore | None = None,
automation_engine: AutomationEngine | None = None,
playlist_engine: PlaylistEngine | None = None,
auto_backup_engine: AutoBackupEngine | None = None,
sync_clock_store: SyncClockStore | None = None,
sync_clock_manager: SyncClockManager | None = None,
@@ -262,7 +274,9 @@ def init_dependencies(
"value_source_store": value_source_store,
"automation_store": automation_store,
"scene_preset_store": scene_preset_store,
"scene_playlist_store": scene_playlist_store,
"automation_engine": automation_engine,
"playlist_engine": playlist_engine,
"auto_backup_engine": auto_backup_engine,
"sync_clock_store": sync_clock_store,
"sync_clock_manager": sync_clock_manager,
@@ -0,0 +1,275 @@
"""Scene playlist API routes — CRUD plus start/stop/state cycling control."""
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_playlist_engine,
get_scene_playlist_store,
get_scene_preset_store,
)
from ledgrab.api.schemas.scene_playlists import (
PlaylistRuntimeStateSchema,
ScenePlaylistCreate,
ScenePlaylistListResponse,
ScenePlaylistResponse,
ScenePlaylistUpdate,
)
from ledgrab.core.scenes.playlist_engine import PlaylistEngine, PlaylistError
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.scene_playlist import PlaylistItem, ScenePlaylist
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
def _playlist_to_response(playlist: ScenePlaylist, engine: PlaylistEngine) -> ScenePlaylistResponse:
return ScenePlaylistResponse(
id=playlist.id,
name=playlist.name,
description=playlist.description,
items=[
{"scene_preset_id": i.scene_preset_id, "duration_seconds": i.duration_seconds}
for i in playlist.items
],
loop=playlist.loop,
shuffle=playlist.shuffle,
order=playlist.order,
tags=playlist.tags,
icon=getattr(playlist, "icon", "") or "",
icon_color=getattr(playlist, "icon_color", "") or "",
is_running=engine.get_running_playlist_id() == playlist.id,
created_at=playlist.created_at,
updated_at=playlist.updated_at,
)
def _items_from_schema(items) -> list[PlaylistItem]:
return [
PlaylistItem(scene_preset_id=i.scene_preset_id, duration_seconds=i.duration_seconds)
for i in items
]
def _validate_preset_refs(items, preset_store: ScenePresetStore) -> None:
"""Reject playlist items that reference a non-existent scene preset."""
for item in items:
try:
preset_store.get_preset(item.scene_preset_id)
except (ValueError, EntityNotFoundError):
raise HTTPException(
status_code=400,
detail=f"Scene preset not found: {item.scene_preset_id}",
)
# ===== CRUD =====
@router.post(
"/api/v1/scene-playlists",
response_model=ScenePlaylistResponse,
tags=["Scene Playlists"],
status_code=201,
)
async def create_scene_playlist(
data: ScenePlaylistCreate,
_auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
preset_store: ScenePresetStore = Depends(get_scene_preset_store),
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Create a new scene playlist."""
_validate_preset_refs(data.items, preset_store)
now = datetime.now(timezone.utc)
playlist = ScenePlaylist(
id=f"playlist_{uuid.uuid4().hex[:8]}",
name=data.name,
description=data.description,
items=_items_from_schema(data.items),
loop=data.loop,
shuffle=data.shuffle,
order=store.count(),
tags=data.tags if data.tags is not None else [],
icon=data.icon or "",
icon_color=data.icon_color or "",
created_at=now,
updated_at=now,
)
try:
playlist = store.create_playlist(playlist)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("scene_playlist", "created", playlist.id)
return _playlist_to_response(playlist, engine)
@router.get(
"/api/v1/scene-playlists",
response_model=ScenePlaylistListResponse,
tags=["Scene Playlists"],
)
async def list_scene_playlists(
_auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""List all scene playlists plus the current cycling state."""
playlists = store.get_all_playlists()
return ScenePlaylistListResponse(
playlists=[_playlist_to_response(p, engine) for p in playlists],
count=len(playlists),
state=PlaylistRuntimeStateSchema(**engine.get_state()),
)
# NOTE: the static ``/state`` path is declared before ``/{playlist_id}`` so it
# is matched first and not swallowed by the path parameter.
@router.get(
"/api/v1/scene-playlists/state",
response_model=PlaylistRuntimeStateSchema,
tags=["Scene Playlists"],
)
async def get_playlist_state(
_auth: AuthRequired,
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Get the current playlist cycling state (idle if nothing is running)."""
return PlaylistRuntimeStateSchema(**engine.get_state())
@router.get(
"/api/v1/scene-playlists/{playlist_id}",
response_model=ScenePlaylistResponse,
tags=["Scene Playlists"],
)
async def get_scene_playlist(
playlist_id: str,
_auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Get a single scene playlist."""
try:
playlist = store.get_playlist(playlist_id)
except (ValueError, EntityNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
return _playlist_to_response(playlist, engine)
@router.put(
"/api/v1/scene-playlists/{playlist_id}",
response_model=ScenePlaylistResponse,
tags=["Scene Playlists"],
)
async def update_scene_playlist(
playlist_id: str,
data: ScenePlaylistUpdate,
_auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
preset_store: ScenePresetStore = Depends(get_scene_preset_store),
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Update a scene playlist's metadata, items, and playback flags."""
new_items = None
if data.items is not None:
_validate_preset_refs(data.items, preset_store)
new_items = _items_from_schema(data.items)
try:
playlist = store.update_playlist(
playlist_id,
name=data.name,
description=data.description,
items=new_items,
loop=data.loop,
shuffle=data.shuffle,
order=data.order,
tags=data.tags,
icon=data.icon,
icon_color=data.icon_color,
)
except (ValueError, EntityNotFoundError) as e:
raise HTTPException(
status_code=404 if "not found" in str(e).lower() else 400, detail=str(e)
)
fire_entity_event("scene_playlist", "updated", playlist_id)
return _playlist_to_response(playlist, engine)
@router.delete(
"/api/v1/scene-playlists/{playlist_id}",
status_code=204,
tags=["Scene Playlists"],
)
async def delete_scene_playlist(
playlist_id: str,
_auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Delete a scene playlist (stops it first if it is currently cycling)."""
try:
store.delete_playlist(playlist_id)
except (ValueError, EntityNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
await engine.stop_if_running(playlist_id)
fire_entity_event("scene_playlist", "deleted", playlist_id)
# ===== Cycling control =====
@router.post(
"/api/v1/scene-playlists/{playlist_id}/start",
response_model=PlaylistRuntimeStateSchema,
tags=["Scene Playlists"],
)
async def start_scene_playlist(
playlist_id: str,
_auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Start cycling a playlist (stops any currently-running playlist first)."""
try:
store.get_playlist(playlist_id)
except (ValueError, EntityNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
try:
await engine.start_playlist(playlist_id)
except PlaylistError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("scene_playlist", "updated", playlist_id)
return PlaylistRuntimeStateSchema(**engine.get_state())
@router.post(
"/api/v1/scene-playlists/stop",
response_model=PlaylistRuntimeStateSchema,
tags=["Scene Playlists"],
)
async def stop_scene_playlist(
_auth: AuthRequired,
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Stop the active playlist (leaves the last applied scene in place)."""
stopped_id = engine.get_running_playlist_id()
await engine.stop()
if stopped_id:
fire_entity_event("scene_playlist", "updated", stopped_id)
return PlaylistRuntimeStateSchema(**engine.get_state())
@@ -0,0 +1,94 @@
"""Scene playlist API schemas."""
from datetime import datetime
from typing import List
from pydantic import BaseModel, Field
from ledgrab.storage.scene_playlist import (
MAX_DURATION_SECONDS,
MIN_DURATION_SECONDS,
)
class PlaylistItemSchema(BaseModel):
scene_preset_id: str = Field(min_length=1, description="Referenced scene preset id")
duration_seconds: float = Field(
default=30.0,
ge=MIN_DURATION_SECONDS,
le=MAX_DURATION_SECONDS,
description="How long to hold this scene before advancing",
)
class ScenePlaylistCreate(BaseModel):
"""Create a scene playlist."""
name: str = Field(description="Playlist name", min_length=1, max_length=100)
description: str = Field(default="", max_length=500)
items: List[PlaylistItemSchema] = Field(
default_factory=list, description="Ordered playlist items"
)
loop: bool = Field(default=True, description="Restart from the first item after the last")
shuffle: bool = Field(default=False, description="Randomise item order each cycle")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
icon: str | None = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: str | None = Field(
None,
max_length=32,
description="Optional CSS color override for the icon.",
)
class ScenePlaylistUpdate(BaseModel):
"""Update scene playlist metadata, items, and playback flags."""
name: str | None = Field(None, min_length=1, max_length=100)
description: str | None = Field(None, max_length=500)
items: List[PlaylistItemSchema] | None = Field(None, description="Replace the item list")
loop: bool | None = None
shuffle: bool | None = None
order: int | None = None
tags: List[str] | None = None
icon: str | None = Field(None, max_length=64)
icon_color: str | None = Field(None, max_length=32)
class PlaylistRuntimeStateSchema(BaseModel):
is_running: bool = False
playlist_id: str | None = None
playlist_name: str | None = None
current_index: int = 0
item_count: int = 0
current_preset_id: str | None = None
started_at: datetime | None = None
step_started_at: datetime | None = None
step_duration: float = 0.0
class ScenePlaylistResponse(BaseModel):
"""Scene playlist with items and runtime running flag."""
id: str
name: str
description: str
items: List[PlaylistItemSchema]
loop: bool
shuffle: bool
order: int
tags: List[str] = Field(default_factory=list)
icon: str | None = Field(None, max_length=64)
icon_color: str | None = Field(None, max_length=32)
is_running: bool = Field(default=False, description="True if this playlist is cycling now")
created_at: datetime
updated_at: datetime
class ScenePlaylistListResponse(BaseModel):
playlists: List[ScenePlaylistResponse]
count: int
state: PlaylistRuntimeStateSchema
@@ -0,0 +1,280 @@
"""Playlist engine — background loop that auto-cycles a scene playlist.
A playlist is an ordered, timed sequence of scene presets. The engine drives
**at most one** playlist at a time: starting a new playlist transparently stops
any currently-running one. Each cycle re-reads the playlist from the store, so
edits (and deletion) take effect at the next cycle boundary without a restart.
The actual state application reuses ``scene_activator.apply_scene_state`` — the
same code path the scene-presets API and the automation engine use — so a
playlist step behaves exactly like manually activating that preset.
"""
import asyncio
import random
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import List
from ledgrab.storage.scene_playlist import ScenePlaylist, clamp_duration
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@dataclass
class PlaylistRuntimeState:
"""Volatile runtime state of the (single) active playlist. Not persisted."""
playlist_id: str
playlist_name: str
current_index: int
item_count: int
current_preset_id: str | None
started_at: datetime
step_started_at: datetime
step_duration: float
def to_dict(self) -> dict:
return {
"is_running": True,
"playlist_id": self.playlist_id,
"playlist_name": self.playlist_name,
"current_index": self.current_index,
"item_count": self.item_count,
"current_preset_id": self.current_preset_id,
"started_at": self.started_at.isoformat(),
"step_started_at": self.step_started_at.isoformat(),
"step_duration": self.step_duration,
}
_IDLE_STATE = {
"is_running": False,
"playlist_id": None,
"playlist_name": None,
"current_index": 0,
"item_count": 0,
"current_preset_id": None,
"started_at": None,
"step_started_at": None,
"step_duration": 0.0,
}
class PlaylistError(Exception):
"""Raised when a playlist cannot be started (empty / not found)."""
class PlaylistEngine:
"""Cycles a scene playlist's presets on a timer, one playlist at a time."""
def __init__(
self,
playlist_store,
scene_preset_store,
target_store,
processor_manager,
):
self._playlist_store = playlist_store
self._scene_preset_store = scene_preset_store
self._target_store = target_store
self._manager = processor_manager
self._task: asyncio.Task | None = None
self._state: PlaylistRuntimeState | None = None
# Serialises start/stop so overlapping API calls can't leave two
# cycling tasks alive at once.
self._lifecycle_lock = asyncio.Lock()
# ===== Public control API =====
async def start_playlist(self, playlist_id: str) -> PlaylistRuntimeState:
"""Start cycling ``playlist_id``, stopping any current playlist first.
Raises ``PlaylistError`` if the playlist is unknown or has no items.
"""
try:
playlist = self._playlist_store.get_playlist(playlist_id)
except Exception as exc: # EntityNotFoundError / ValueError
raise PlaylistError(f"Playlist not found: {playlist_id}") from exc
if not playlist.items:
raise PlaylistError(f"Playlist '{playlist.name}' has no items")
async with self._lifecycle_lock:
await self._cancel_task()
now = datetime.now(timezone.utc)
first_item = playlist.items[0]
self._state = PlaylistRuntimeState(
playlist_id=playlist.id,
playlist_name=playlist.name,
current_index=0,
item_count=len(playlist.items),
current_preset_id=first_item.scene_preset_id,
started_at=now,
step_started_at=now,
step_duration=clamp_duration(first_item.duration_seconds),
)
self._task = asyncio.create_task(self._run(playlist.id))
self._fire_event("started")
logger.info("Playlist '%s' started (%d items)", playlist.name, len(playlist.items))
return self._state
async def stop(self) -> None:
"""Stop the active playlist (if any). Leaves the last scene applied."""
async with self._lifecycle_lock:
was_running = self._task is not None
await self._cancel_task()
stopped_id = self._state.playlist_id if self._state else None
self._state = None
if was_running:
self._fire_event("stopped", playlist_id=stopped_id)
logger.info("Playlist stopped")
async def stop_if_running(self, playlist_id: str) -> None:
"""Stop the playlist only if ``playlist_id`` is the one running.
Used when a playlist is deleted or edited so a stale snapshot can't keep
cycling.
"""
if self._state is not None and self._state.playlist_id == playlist_id:
await self.stop()
# ===== Query API (used by routes) =====
def is_running(self) -> bool:
return self._task is not None and not self._task.done()
def get_running_playlist_id(self) -> str | None:
return self._state.playlist_id if self._state else None
def get_state(self) -> dict:
if self._state is not None and self.is_running():
return self._state.to_dict()
return dict(_IDLE_STATE)
# ===== Internal =====
async def _cancel_task(self) -> None:
"""Cancel and await the cycling task. Caller holds the lifecycle lock."""
task = self._task
self._task = None
if task is None:
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception as exc: # pragma: no cover - defensive
logger.error("Playlist task raised on cancel: %s", exc, exc_info=True)
async def _run(self, playlist_id: str) -> None:
"""Cycle the playlist until cancelled, the playlist ends, or it errors."""
try:
while True:
# Re-read each cycle so edits/deletes apply at the boundary.
try:
playlist = self._playlist_store.get_playlist(playlist_id)
except Exception:
logger.info("Playlist %s removed while running; stopping", playlist_id)
break
if not playlist.items:
logger.info("Playlist '%s' has no items; stopping", playlist.name)
break
applied_any = await self._run_cycle(playlist)
if not playlist.loop:
break
if not applied_any:
# Every item referenced a missing preset — a looping
# playlist would otherwise spin with no dwell. Bail out.
logger.warning(
"Playlist '%s' applied no valid presets this cycle; stopping",
playlist.name,
)
break
# Natural end (non-loop or guard). Clear state without recursing
# through stop() (which would try to cancel this very task). Guard
# against a concurrent start_playlist having already replaced us:
# only clear if we are still the engine's current task.
if self._task is asyncio.current_task():
self._task = None
ended_id = self._state.playlist_id if self._state else None
self._state = None
self._fire_event("stopped", playlist_id=ended_id)
logger.info("Playlist '%s' finished", playlist_id)
except asyncio.CancelledError:
raise
except Exception as exc: # pragma: no cover - defensive
logger.error("Playlist run loop error: %s", exc, exc_info=True)
async def _run_cycle(self, playlist: ScenePlaylist) -> bool:
"""Run one pass over the playlist's items. Returns True if any applied."""
order = self._resolve_order(playlist)
applied_any = False
for index, item in enumerate(order):
duration = clamp_duration(item.duration_seconds)
if self._state is not None:
self._state.current_index = index
self._state.current_preset_id = item.scene_preset_id
self._state.step_started_at = datetime.now(timezone.utc)
self._state.step_duration = duration
applied = await self._apply_item(item.scene_preset_id)
if applied:
applied_any = True
self._fire_event("advanced", index=index, preset_id=item.scene_preset_id)
# Only dwell on scenes we actually applied; skip missing ones
# immediately so the cycle doesn't stall on a dead reference.
await asyncio.sleep(duration)
return applied_any
def _resolve_order(self, playlist: ScenePlaylist) -> List:
if playlist.shuffle and len(playlist.items) > 1:
shuffled = list(playlist.items)
random.shuffle(shuffled) # noqa: S311 - cosmetic ordering, not security
return shuffled
return list(playlist.items)
async def _apply_item(self, preset_id: str) -> bool:
"""Apply one scene preset. Returns False if it could not be applied."""
if not self._scene_preset_store or not self._target_store or not self._manager:
logger.warning("Playlist engine missing stores; cannot apply %s", preset_id)
return False
try:
preset = self._scene_preset_store.get_preset(preset_id)
except Exception:
logger.warning("Playlist references missing scene preset %s (skipped)", preset_id)
return False
from ledgrab.core.scenes.scene_activator import apply_scene_state
_status, errors = await apply_scene_state(preset, self._target_store, self._manager)
if errors:
logger.warning("Playlist step '%s' applied with errors: %s", preset.name, errors)
return True
def _fire_event(self, action: str, **extra) -> None:
if self._manager is None:
return
try:
self._manager.fire_event(
{
"type": "playlist_state_changed",
"action": action,
"playlist_id": extra.get("playlist_id")
or (self._state.playlist_id if self._state else None),
**{k: v for k, v in extra.items() if k != "playlist_id"},
}
)
except Exception as exc:
logger.error("Playlist event fire failed: %s", exc, exc_info=True)
+17
View File
@@ -35,6 +35,7 @@ import ledgrab.core.audio # noqa: F401 — trigger engine auto-registration
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
from ledgrab.storage.sync_clock_store import SyncClockStore
from ledgrab.storage.color_strip_processing_template_store import (
ColorStripProcessingTemplateStore,
@@ -47,6 +48,7 @@ from ledgrab.core.weather.weather_manager import WeatherManager
from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
@@ -157,6 +159,7 @@ audio_template_store = AudioTemplateStore(db)
value_source_store = ValueSourceStore(db)
automation_store = AutomationStore(db)
scene_preset_store = ScenePresetStore(db)
scene_playlist_store = ScenePlaylistStore(db)
sync_clock_store = SyncClockStore(db)
cspt_store = ColorStripProcessingTemplateStore(db)
gradient_store = GradientStore(db)
@@ -278,6 +281,15 @@ async def lifespan(app: FastAPI):
value_source_store=value_source_store,
)
# Create playlist engine — auto-cycles scene presets, one playlist at a
# time. Idle (no background task) until a playlist is started via the API.
playlist_engine = PlaylistEngine(
playlist_store=scene_playlist_store,
scene_preset_store=scene_preset_store,
target_store=output_target_store,
processor_manager=processor_manager,
)
# Create auto-backup engine — derive paths from database location so that
# demo mode auto-backups go to data/demo/ instead of data/.
_data_dir = Path(config.storage.database_file).parent
@@ -314,7 +326,9 @@ async def lifespan(app: FastAPI):
value_source_store=value_source_store,
automation_store=automation_store,
scene_preset_store=scene_preset_store,
scene_playlist_store=scene_playlist_store,
automation_engine=automation_engine,
playlist_engine=playlist_engine,
auto_backup_engine=auto_backup_engine,
sync_clock_store=sync_clock_store,
sync_clock_manager=sync_clock_manager,
@@ -436,6 +450,9 @@ async def lifespan(app: FastAPI):
# would talk to processors mid-shutdown.
await _bounded("automation_engine.stop", automation_engine.stop(), timeout=1.5)
# Stop the playlist engine so its cycling task can't apply scenes mid-shutdown.
await _bounded("playlist_engine.stop", playlist_engine.stop(), timeout=1.0)
# Stop discovery watcher and OS notification listener so they stop
# firing events into a shutting-down processor manager.
if discovery_watcher is not None:
@@ -1134,6 +1134,175 @@ textarea:focus-visible {
cursor: not-allowed;
}
/* ── Scene playlist items — ordered, timed channel rows ──────────
Mirrors .scene-target-* (cyan patch-bay) but adds a per-item dwell
duration field and reorder controls. Slot index via CSS counter so
DOM reorders need no JS renumbering. Paired with
.ds-section[data-ch="cyan"] in scene-playlist-editor.html. */
.playlist-item-list {
--st-ch: var(--ch-cyan, var(--info-color, #00d8ff));
counter-reset: st-slot;
display: flex;
flex-direction: column;
gap: 4px;
padding: 0;
}
.playlist-item-list:empty::before {
content: attr(data-empty);
display: block;
padding: 14px 12px;
font-size: 0.78rem;
color: var(--lux-ink-dim, var(--text-secondary));
border: 1px dashed color-mix(in srgb, var(--st-ch) 40%, var(--lux-line, var(--border-color)));
border-radius: var(--lux-r-md, 6px);
background:
repeating-linear-gradient(135deg,
color-mix(in srgb, var(--st-ch) 4%, transparent) 0 6px,
transparent 6px 12px);
text-align: center;
letter-spacing: 0.04em;
}
.playlist-item {
counter-increment: st-slot;
position: relative;
display: grid;
grid-template-columns: 26px 32px minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 6px 8px 6px 6px;
border: 1px solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, 6px);
background:
linear-gradient(180deg,
color-mix(in srgb, var(--st-ch) 3%, var(--lux-bg-2, var(--bg-secondary))) 0%,
color-mix(in srgb, var(--lux-bg-1, var(--card-bg)) 70%, transparent) 100%);
font-size: 0.85rem;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.playlist-item:hover {
border-color: color-mix(in srgb, var(--st-ch) 55%, var(--lux-line, var(--border-color)));
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--st-ch) 80%, transparent);
}
.playlist-item::before {
content: counter(st-slot, decimal-leading-zero);
grid-column: 1;
justify-self: center;
font-family: var(--font-mono);
font-size: 0.65rem;
font-weight: 600;
color: color-mix(in srgb, var(--st-ch) 75%, var(--lux-ink-dim, var(--text-secondary)));
opacity: 0.85;
}
.playlist-item-icon {
grid-column: 2;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: var(--st-ch);
background: color-mix(in srgb, var(--st-ch) 9%, transparent);
border: 1px solid color-mix(in srgb, var(--st-ch) 22%, transparent);
border-radius: 5px;
flex-shrink: 0;
}
.playlist-item-icon svg,
.playlist-item-icon .icon { width: 18px; height: 18px; }
.playlist-item-icon .scene-color-dot { width: 12px; height: 12px; border-radius: 50%; }
.playlist-item-id {
grid-column: 3;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.playlist-item-name {
font-weight: 600;
color: var(--lux-ink, var(--text-color));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.25;
}
.playlist-item-type {
align-self: flex-start;
font-family: var(--font-mono);
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
color: color-mix(in srgb, var(--st-ch) 70%, var(--lux-ink-dim, var(--text-secondary)));
padding: 1px 5px;
border: 1px solid color-mix(in srgb, var(--st-ch) 28%, transparent);
border-radius: 2px;
line-height: 1.2;
}
.playlist-item-type.playlist-item--missing {
color: var(--ch-coral, var(--danger-color, #ff5e5e));
border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 40%, transparent);
}
.playlist-item-duration-wrap {
grid-column: 4;
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--lux-ink-dim, var(--text-secondary));
}
.playlist-item-duration-wrap svg,
.playlist-item-duration-wrap .icon { width: 13px; height: 13px; opacity: 0.7; }
.playlist-item-duration {
width: 52px;
padding: 3px 5px;
text-align: right;
font-family: var(--font-mono);
font-size: 0.8rem;
border: 1px solid var(--lux-line, var(--border-color));
border-radius: 4px;
background: var(--bg-color, transparent);
color: var(--lux-ink, var(--text-color));
}
.playlist-item-unit {
font-family: var(--font-mono);
font-size: 0.72rem;
opacity: 0.7;
}
.playlist-item-actions {
grid-column: 5;
display: inline-flex;
gap: 2px;
}
.playlist-item-btn {
background: none;
border: 1px solid transparent;
color: var(--lux-ink-dim, var(--text-secondary));
width: 24px;
height: 26px;
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0.75;
font-size: 0.9rem;
transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.playlist-item-btn:hover,
.playlist-item-btn:focus-visible {
opacity: 1;
color: var(--st-ch);
background: color-mix(in srgb, var(--st-ch) 10%, transparent);
border-color: color-mix(in srgb, var(--st-ch) 30%, transparent);
outline: none;
}
.playlist-item-btn.playlist-item-remove:hover,
.playlist-item-btn.playlist-item-remove:focus-visible {
color: var(--ch-coral, var(--danger-color, #ff5e5e));
background: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 10%, transparent);
border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 35%, transparent);
}
.playlist-item-btn .icon,
.playlist-item-btn svg { width: 14px; height: 14px; }
/* ── Icon Select (reusable type picker) ──────────────────────── */
.icon-select-trigger {
+16
View File
@@ -116,6 +116,11 @@ import {
activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset,
addSceneTarget,
} from './features/scene-presets.ts';
import {
openPlaylistEditor, editPlaylist, savePlaylist, closePlaylistEditor,
clonePlaylist, deletePlaylist, addPlaylistItem,
startScenePlaylist, stopScenePlaylist,
} from './features/scene-playlists.ts';
// Layer 5: device-discovery, targets
import {
@@ -463,6 +468,17 @@ Object.assign(window, {
recaptureScenePreset,
addSceneTarget,
// scene playlists — modal buttons + mod-card inline handlers
openPlaylistEditor,
editPlaylist,
savePlaylist,
closePlaylistEditor,
clonePlaylist,
deletePlaylist,
addPlaylistItem,
startScenePlaylist,
stopScenePlaylist,
// integrations
loadIntegrations,
switchIntegrationTab,
@@ -8,7 +8,7 @@
import {
devicesCache, outputTargetsCache, colorStripSourcesCache,
streamsCache, audioSourcesCache, valueSourcesCache,
syncClocksCache, automationsCacheObj, scenePresetsCache,
syncClocksCache, automationsCacheObj, scenePresetsCache, scenePlaylistsCache,
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
patternTemplatesCache,
weatherSourcesCache, haSourcesCache, mqttSourcesCache,
@@ -26,6 +26,7 @@ const ENTITY_CACHE_MAP = {
sync_clock: syncClocksCache,
automation: automationsCacheObj,
scene_preset: scenePresetsCache,
scene_playlist: scenePlaylistsCache,
capture_template: captureTemplatesCache,
audio_template: audioTemplatesCache,
pp_template: ppTemplatesCache,
@@ -51,6 +52,7 @@ const ENTITY_LOADER_MAP = {
pp_template: 'loadPictureSources',
automation: 'loadAutomations',
scene_preset: 'loadAutomations',
scene_playlist: 'loadAutomations',
weather_source: 'loadIntegrations',
home_assistant_source: 'loadIntegrations',
mqtt_source: 'loadIntegrations',
@@ -40,6 +40,7 @@ const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet<string> = new Set([
'server_restarting',
'state_change',
'automation_state_changed',
'playlist_state_changed',
'entity_changed',
'device_health_changed',
'update_available',
+6 -1
View File
@@ -15,7 +15,7 @@
import { DataCache } from './cache.ts';
import type {
Device, OutputTarget, ColorStripSource, PatternTemplate,
ValueSource, AudioSource, PictureSource, ScenePreset,
ValueSource, AudioSource, PictureSource, ScenePreset, ScenePlaylist,
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo,
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
@@ -436,6 +436,11 @@ export const scenePresetsCache = new DataCache<ScenePreset[]>({
extractData: json => json.presets || [],
});
export const scenePlaylistsCache = new DataCache<ScenePlaylist[]>({
endpoint: '/scene-playlists',
extractData: json => json.playlists || [],
});
export interface GradientEntity {
id: string;
name: string;
@@ -4,7 +4,7 @@
import {
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
scenePresetsCache, _cachedHASources, haSourcesCache,
scenePresetsCache, scenePlaylistsCache, _cachedHASources, haSourcesCache,
_cachedValueSources, valueSourcesCache,
getHAEntityFriendlyName, setHAEntityNames,
} from '../core/state.ts';
@@ -18,7 +18,7 @@ import { Modal } from '../core/modal.ts';
import { CardSection } from '../core/card-sections.ts';
import { updateTabBadge, updateSubTabHash } from './tabs.ts';
import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE } from '../core/icons.ts';
import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE, ICON_LIST_CHECKS } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
@@ -32,6 +32,7 @@ import { enhanceMiniSelects } from '../core/mini-select.ts';
import { attachProcessPicker, attachAppPicker } from '../core/process-picker.ts';
import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
import { csPlaylists, createPlaylistCard, initPlaylistDelegation } from './scene-playlists.ts';
import type { Automation, RuleType } from '../types.ts';
registerIconEntityType('automation', makeSimpleIconAdapter<Automation>({
@@ -252,6 +253,7 @@ export async function loadAutomations() {
const [automations, scenes] = await Promise.all([
automationsCacheObj.fetch(),
scenePresetsCache.fetch(),
scenePlaylistsCache.fetch(),
haSourcesCache.fetch(),
valueSourcesCache.fetch(),
]);
@@ -291,38 +293,45 @@ function renderAutomations(automations: any, sceneMap: any) {
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) })));
const playlistItems = csPlaylists.applySortOrder(scenePlaylistsCache.data.map(p => ({ key: p.id, html: createPlaylistCard(p) })));
const activeTab = getActiveSubTab('automations')!;
const treeItems = [
{ key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.length },
{ key: 'scenes', icon: ICON_SCENE, titleKey: 'scenes.title', count: scenePresetsCache.data.length },
{ key: 'playlists', icon: ICON_LIST_CHECKS, titleKey: 'playlists.title', count: scenePlaylistsCache.data.length },
];
if (csAutomations.isMounted()) {
_automationsTree.updateCounts({
automations: automations.length,
scenes: scenePresetsCache.data.length,
playlists: scenePlaylistsCache.data.length,
});
csAutomations.reconcile(autoItems);
csScenes.reconcile(sceneItems);
csPlaylists.reconcile(playlistItems);
} else {
const panels = [
{ key: 'automations', html: csAutomations.render(autoItems) },
{ key: 'scenes', html: csScenes.render(sceneItems) },
{ key: 'playlists', html: csPlaylists.render(playlistItems) },
].map(p => `<div class="automation-sub-tab-panel stream-tab-panel${p.key === activeTab ? ' active' : ''}" id="automation-tab-${p.key}">${p.html}</div>`).join('');
container!.innerHTML = panels;
CardSection.bindAll([csAutomations, csScenes]);
CardSection.bindAll([csAutomations, csScenes, csPlaylists]);
// Event delegation for scene preset card actions
// Event delegation for scene preset + playlist card actions
initScenePresetDelegation(container!);
initPlaylistDelegation(container!);
_automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_automationsTree.update(treeItems, activeTab);
_automationsTree.observeSections('automations-content', {
'automations': 'automations',
'scenes': 'scenes',
'playlists': 'playlists',
});
}
}
@@ -0,0 +1,531 @@
/**
* Scene Playlists — ordered, timed sequences of scene presets that auto-cycle.
* Rendered as a CardSection inside the Automations tab (third sub-tab).
*
* A playlist activates each referenced scene preset in turn and holds it for
* that item's dwell duration, then advances. Only one playlist cycles at a
* time; starting one stops any other.
*/
import { escapeHtml } from '../core/api.ts';
import { apiPost, apiPut, apiDelete } from '../core/api-client.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { CardSection } from '../core/card-sections.ts';
import {
ICON_START, ICON_PAUSE, ICON_EDIT, ICON_TRASH, ICON_LINK, ICON_REFRESH, ICON_CLOCK,
} from '../core/icons.ts';
import { renderDeviceIconSvg } from '../core/device-icons.ts';
import { scenePlaylistsCache, scenePresetsCache } from '../core/state.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
import { EntityPalette } from '../core/entity-palette.ts';
import { navigateToCard } from '../core/navigation.ts';
import { isActiveTab } from '../core/tab-registry.ts';
import { makeCardIconFields } from '../core/card-icon.ts';
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
import type { ScenePlaylist, ScenePreset } from '../types.ts';
const DEFAULT_ITEM_DURATION = 30;
const MIN_ITEM_DURATION = 1;
const SCENE_DOT = '<span class="scene-color-dot" style="background:#4fc3f7"></span>';
registerIconEntityType('scene_playlist', makeSimpleIconAdapter<ScenePlaylist>({
cache: scenePlaylistsCache,
endpointPrefix: '/scene-playlists',
reload: async () => {
scenePlaylistsCache.invalidate();
if (typeof window.loadAutomations === 'function') {
await window.loadAutomations();
}
},
typeLabelKey: 'device.icon.entity.scene_playlist',
typeLabelFallback: 'Playlist',
cardSelectors: (id) => [`[data-playlist-id="${CSS.escape(id)}"]`],
}));
let _editingId: string | null = null;
let _playlistTagsInput: TagInput | null = null;
let _presetMap: Record<string, ScenePreset> = {};
// ── Scene-preset lookup helpers ──
async function _primePresets(): Promise<void> {
const presets = await scenePresetsCache.fetch().catch((): ScenePreset[] => []);
_presetMap = {};
for (const p of presets) _presetMap[p.id] = p;
}
function _presetName(presetId: string): string {
return _presetMap[presetId]?.name || presetId;
}
function _presetIconSvg(preset: ScenePreset | undefined): string {
const svg = preset?.icon ? renderDeviceIconSvg(preset.icon, { size: 18 }) : '';
return svg || SCENE_DOT;
}
// ── Item row rendering (editor) ──
function _renderItemRowHtml(presetId: string, duration: number): string {
const preset = _presetMap[presetId];
const removeLabel = t('common.remove') || 'Remove';
const upLabel = t('playlists.item.move_up') || 'Move up';
const downLabel = t('playlists.item.move_down') || 'Move down';
const missing = preset ? '' : ' playlist-item--missing';
return `
<div class="playlist-item-icon" aria-hidden="true">${_presetIconSvg(preset)}</div>
<div class="playlist-item-id">
<span class="playlist-item-name">${escapeHtml(_presetName(presetId))}</span>
<span class="playlist-item-type${missing}">${preset ? 'SCN' : (t('playlists.item.missing') || 'MISSING')}</span>
</div>
<div class="playlist-item-duration-wrap">
${ICON_CLOCK}
<input type="number" class="playlist-item-duration" min="${MIN_ITEM_DURATION}" step="1"
value="${Math.max(MIN_ITEM_DURATION, Math.round(duration))}"
aria-label="${escapeHtml(t('playlists.item.duration') || 'Seconds')}">
<span class="playlist-item-unit">s</span>
</div>
<div class="playlist-item-actions">
<button type="button" class="playlist-item-btn" data-action="playlist-item-up" title="${escapeHtml(upLabel)}" aria-label="${escapeHtml(upLabel)}">&#x2191;</button>
<button type="button" class="playlist-item-btn" data-action="playlist-item-down" title="${escapeHtml(downLabel)}" aria-label="${escapeHtml(downLabel)}">&#x2193;</button>
<button type="button" class="playlist-item-btn playlist-item-remove" data-action="playlist-item-remove" title="${escapeHtml(removeLabel)}" aria-label="${escapeHtml(removeLabel)}">${ICON_TRASH}</button>
</div>
`;
}
function _appendItemRow(presetId: string, duration: number, listEl: HTMLElement): void {
const item = document.createElement('div');
item.className = 'playlist-item';
item.dataset.presetId = presetId;
item.dataset.duration = String(duration);
item.innerHTML = _renderItemRowHtml(presetId, duration);
listEl.appendChild(item);
}
function _readEditorItems(): Array<{ scene_preset_id: string; duration_seconds: number }> {
return [...document.querySelectorAll('#playlist-item-list .playlist-item')].map(el => {
const row = el as HTMLElement;
const input = row.querySelector('.playlist-item-duration') as HTMLInputElement | null;
const raw = input ? parseFloat(input.value) : DEFAULT_ITEM_DURATION;
const duration = Number.isFinite(raw) && raw >= MIN_ITEM_DURATION ? raw : MIN_ITEM_DURATION;
return { scene_preset_id: row.dataset.presetId || '', duration_seconds: duration };
}).filter(i => i.scene_preset_id);
}
function _setItemListEmptyHint(listEl: HTMLElement): void {
listEl.dataset.empty = t('playlists.items.empty') || 'No scenes yet — add some below';
}
// ── Auto-name ──
let _plNameManuallyEdited = false;
function _autoGeneratePlaylistName(): void {
if (_plNameManuallyEdited) return;
if ((document.getElementById('playlist-editor-id') as HTMLInputElement).value) return;
const count = document.querySelectorAll('#playlist-item-list .playlist-item').length;
const label = count > 0
? `${t('playlists.title')} · ${count} ${count === 1 ? (t('playlists.scene_one') || 'scene') : (t('playlists.scene_many') || 'scenes')}`
: t('playlists.title');
(document.getElementById('playlist-editor-name') as HTMLInputElement).value = label;
}
class PlaylistEditorModal extends Modal {
constructor() { super('playlist-editor-modal'); }
onForceClose() {
if (_playlistTagsInput) { _playlistTagsInput.destroy(); _playlistTagsInput = null; }
}
snapshotValues() {
const items = _readEditorItems().map(i => `${i.scene_preset_id}:${i.duration_seconds}`).join(',');
return {
name: (document.getElementById('playlist-editor-name') as HTMLInputElement).value,
description: (document.getElementById('playlist-editor-description') as HTMLInputElement).value,
loop: (document.getElementById('playlist-editor-loop') as HTMLInputElement).checked.toString(),
shuffle: (document.getElementById('playlist-editor-shuffle') as HTMLInputElement).checked.toString(),
items,
tags: JSON.stringify(_playlistTagsInput ? _playlistTagsInput.getValue() : []),
};
}
}
const playlistModal = new PlaylistEditorModal();
export const csPlaylists = new CardSection('playlists', {
titleKey: 'playlists.title',
gridClass: 'devices-grid',
addCardOnclick: 'openPlaylistEditor()',
keyAttr: 'data-playlist-id',
emptyKey: 'section.empty.playlists',
bulkActions: [{
key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete',
handler: async (ids) => {
const results = await Promise.allSettled(ids.map(id => apiDelete(`/scene-playlists/${id}`)));
const failed = results.filter(r => r.status === 'rejected').length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t('playlists.deleted'), 'success');
scenePlaylistsCache.invalidate();
if (window.loadAutomations) window.loadAutomations();
},
}],
});
export function createPlaylistCard(playlist: ScenePlaylist): string {
const itemCount = (playlist.items || []).length;
const running = playlist.is_running === true;
const updated = playlist.updated_at ? new Date(playlist.updated_at).toLocaleString() : '';
const shortId = (playlist.id || '').replace(/^playlist_/i, '').slice(-2).toUpperCase() || 'NA';
const metaParts: string[] = [];
if (itemCount > 0) metaParts.push(`${itemCount} ${t('playlists.scenes_count')}`);
if (updated) metaParts.push(updated);
const metaHtml = metaParts.length ? metaParts.map(escapeHtml).join(' · ') : undefined;
const chips: ModChipOpts[] = [];
if (playlist.loop) chips.push({ icon: ICON_REFRESH, text: t('playlists.chip.loop'), variant: 'tag' });
if (playlist.shuffle) chips.push({ icon: ICON_LINK, text: t('playlists.chip.shuffle'), variant: 'tag' });
const leds: LedState[] = [running ? 'on' : 'off'];
const primaryAction = running
? {
label: t('playlists.action.stop'),
icon: ICON_PAUSE,
onclick: `stopScenePlaylist()`,
title: t('playlists.stop'),
variant: 'stop' as const,
}
: {
label: t('playlists.action.start'),
icon: ICON_START,
onclick: `startScenePlaylist('${playlist.id}')`,
title: t('playlists.start'),
variant: 'go' as const,
};
const mod: ModCardOpts = {
head: {
badge: { text: `PL · ${shortId}` },
name: playlist.name,
metaHtml,
leds,
...makeCardIconFields('scene_playlist', playlist.id, playlist),
menu: {
duplicateOnclick: `clonePlaylist('${playlist.id}')`,
hideOnclick: `toggleCardHidden('playlists','${playlist.id}')`,
deleteOnclick: `deletePlaylist('${playlist.id}')`,
},
},
body: {
desc: playlist.description || undefined,
chips: chips.length ? chips : undefined,
},
foot: {
patchState: running ? 'live' : 'idle',
patchLabel: running ? t('playlists.status.playing') : t('playlists.status.stopped'),
primaryAction,
iconActions: [{
icon: ICON_EDIT,
onclick: `editPlaylist('${playlist.id}')`,
title: t('playlists.edit'),
}],
},
};
const cardHtml = wrapCard({ dataAttr: 'data-playlist-id', id: playlist.id, mod });
const tagsHtml = renderTagChips(playlist.tags);
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
}
export async function loadPlaylists(): Promise<ScenePlaylist[]> {
return scenePlaylistsCache.fetch();
}
// ===== Create / Edit / Clone =====
function _resetEditorChrome(titleKey: string): void {
(document.getElementById('playlist-editor-error') as HTMLElement).style.display = 'none';
const titleEl = document.querySelector('#playlist-editor-title span[data-i18n]');
if (titleEl) { titleEl.setAttribute('data-i18n', titleKey); titleEl.textContent = t(titleKey); }
}
function _wireAutoName(): void {
_plNameManuallyEdited = false;
(document.getElementById('playlist-editor-name') as HTMLElement).oninput = () => { _plNameManuallyEdited = true; };
}
function _initTags(values: string[]): void {
if (_playlistTagsInput) { _playlistTagsInput.destroy(); _playlistTagsInput = null; }
_playlistTagsInput = new TagInput(document.getElementById('playlist-tags-container'), { placeholder: t('tags.placeholder') });
_playlistTagsInput.setValue(values);
}
async function _openEditorWith(opts: {
editingId: string | null;
name: string;
description: string;
loop: boolean;
shuffle: boolean;
items: Array<{ scene_preset_id: string; duration_seconds: number }>;
tags: string[];
titleKey: string;
}): Promise<void> {
_editingId = opts.editingId;
(document.getElementById('playlist-editor-id') as HTMLInputElement).value = opts.editingId || '';
(document.getElementById('playlist-editor-name') as HTMLInputElement).value = opts.name;
(document.getElementById('playlist-editor-description') as HTMLInputElement).value = opts.description;
(document.getElementById('playlist-editor-loop') as HTMLInputElement).checked = opts.loop;
(document.getElementById('playlist-editor-shuffle') as HTMLInputElement).checked = opts.shuffle;
_resetEditorChrome(opts.titleKey);
const list = document.getElementById('playlist-item-list');
if (list) {
list.innerHTML = '';
_setItemListEmptyHint(list);
await _primePresets();
for (const it of opts.items) _appendItemRow(it.scene_preset_id, it.duration_seconds, list);
}
_initTags(opts.tags);
_wireAutoName();
if (!opts.editingId) _autoGeneratePlaylistName();
playlistModal.open();
playlistModal.snapshot();
}
export async function openPlaylistEditor(): Promise<void> {
await _openEditorWith({
editingId: null, name: '', description: '', loop: true, shuffle: false,
items: [], tags: [], titleKey: 'playlists.add',
});
}
export async function editPlaylist(playlistId: string): Promise<void> {
const playlist = scenePlaylistsCache.data.find(p => p.id === playlistId);
if (!playlist) return;
await _openEditorWith({
editingId: playlistId,
name: playlist.name,
description: playlist.description || '',
loop: playlist.loop !== false,
shuffle: playlist.shuffle === true,
items: (playlist.items || []).map(i => ({ scene_preset_id: i.scene_preset_id, duration_seconds: i.duration_seconds })),
tags: playlist.tags || [],
titleKey: 'playlists.edit',
});
}
export async function clonePlaylist(playlistId: string): Promise<void> {
const playlist = scenePlaylistsCache.data.find(p => p.id === playlistId);
if (!playlist) return;
await _openEditorWith({
editingId: null,
name: `${playlist.name || ''} (Copy)`,
description: playlist.description || '',
loop: playlist.loop !== false,
shuffle: playlist.shuffle === true,
items: (playlist.items || []).map(i => ({ scene_preset_id: i.scene_preset_id, duration_seconds: i.duration_seconds })),
tags: playlist.tags || [],
titleKey: 'playlists.add',
});
}
export async function savePlaylist(): Promise<void> {
if (playlistModal.closeIfPristine(_editingId)) return;
const name = (document.getElementById('playlist-editor-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('playlist-editor-description') as HTMLInputElement).value.trim();
const loop = (document.getElementById('playlist-editor-loop') as HTMLInputElement).checked;
const shuffle = (document.getElementById('playlist-editor-shuffle') as HTMLInputElement).checked;
const errorEl = document.getElementById('playlist-editor-error')!;
if (!name) {
errorEl.textContent = t('playlists.error.name_required');
errorEl.style.display = 'block';
return;
}
const items = _readEditorItems();
const tags = _playlistTagsInput ? _playlistTagsInput.getValue() : [];
const body = { name, description, loop, shuffle, items, tags };
try {
if (_editingId) {
await apiPut(`/scene-playlists/${_editingId}`, body, { errorMessage: t('playlists.error.save_failed') });
} else {
await apiPost('/scene-playlists', body, { errorMessage: t('playlists.error.save_failed') });
}
playlistModal.forceClose();
showToast(_editingId ? t('playlists.updated') : t('playlists.created'), 'success');
scenePlaylistsCache.invalidate();
_reloadPlaylistsTab();
} catch (error: any) {
if (error.isAuth) return;
errorEl.textContent = error.message || t('playlists.error.save_failed');
errorEl.style.display = 'block';
}
}
export async function closePlaylistEditor(): Promise<void> {
await playlistModal.close();
}
// ===== Item selector =====
export async function addPlaylistItem(): Promise<void> {
await _primePresets();
const presets = Object.values(_presetMap);
if (presets.length === 0) {
showToast(t('playlists.error.no_presets') || 'Create a scene preset first', 'warning');
return;
}
const items = presets.map(p => ({
value: p.id,
label: p.name,
icon: _presetIconSvg(p),
}));
const picked = await EntityPalette.pick({
items,
placeholder: t('playlists.items.search_placeholder'),
});
if (!picked) return;
const list = document.getElementById('playlist-item-list');
if (list) {
_appendItemRow(String(picked), DEFAULT_ITEM_DURATION, list);
_autoGeneratePlaylistName();
}
}
// ===== Start / Stop =====
export async function startScenePlaylist(playlistId: string): Promise<void> {
try {
await apiPost(`/scene-playlists/${playlistId}/start`, undefined, { errorMessage: t('playlists.error.start_failed') });
showToast(t('playlists.started'), 'success');
scenePlaylistsCache.invalidate();
_reloadPlaylistsTab();
} catch (error: any) {
if (error.isAuth) return;
showToast(error.message || t('playlists.error.start_failed'), 'error');
}
}
export async function stopScenePlaylist(): Promise<void> {
try {
await apiPost('/scene-playlists/stop', undefined, { errorMessage: t('playlists.error.stop_failed') });
showToast(t('playlists.stopped'), 'success');
scenePlaylistsCache.invalidate();
_reloadPlaylistsTab();
} catch (error: any) {
if (error.isAuth) return;
showToast(error.message || t('playlists.error.stop_failed'), 'error');
}
}
// ===== Delete =====
export async function deletePlaylist(playlistId: string): Promise<void> {
const playlist = scenePlaylistsCache.data.find(p => p.id === playlistId);
const name = playlist ? playlist.name : playlistId;
const confirmed = await showConfirm(t('playlists.delete_confirm', { name }));
if (!confirmed) return;
try {
await apiDelete(`/scene-playlists/${playlistId}`, { errorMessage: t('playlists.error.delete_failed') });
showToast(t('playlists.deleted'), 'success');
scenePlaylistsCache.invalidate();
_reloadPlaylistsTab();
} catch (error: any) {
if (error.isAuth) return;
showToast(error.message || t('playlists.error.delete_failed'), 'error');
}
}
// ===== Event delegation =====
const _playlistCardActions: Record<string, (id: string) => void> = {
'delete-playlist': deletePlaylist,
'clone-playlist': clonePlaylist,
'edit-playlist': editPlaylist,
'start-playlist': startScenePlaylist,
};
export function initPlaylistDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (!action) return;
if (action === 'add-playlist') {
e.stopPropagation();
openPlaylistEditor();
return;
}
if (action === 'stop-playlist') {
e.stopPropagation();
stopScenePlaylist();
return;
}
if (action === 'navigate-playlist') {
if ((e.target as HTMLElement).closest('button')) return;
navigateToCard('automations', 'playlists', 'playlists', 'data-playlist-id', id!);
return;
}
if (!id) return;
const handler = _playlistCardActions[action];
if (handler) {
e.stopPropagation();
handler(id);
}
});
}
// ===== Helpers =====
function _reloadPlaylistsTab(): void {
if (isActiveTab('automations') && typeof window.loadAutomations === 'function') {
window.loadAutomations();
}
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
}
// ===== Editor modal item-list delegation (reorder / remove / duration) =====
const _playlistEditorModal = document.getElementById('playlist-editor-modal');
if (_playlistEditorModal) {
_playlistEditorModal.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const row = btn.closest('.playlist-item') as HTMLElement | null;
if (!row) return;
if (action === 'playlist-item-remove') {
row.remove();
_autoGeneratePlaylistName();
} else if (action === 'playlist-item-up') {
const prev = row.previousElementSibling;
if (prev) row.parentElement!.insertBefore(row, prev);
} else if (action === 'playlist-item-down') {
const next = row.nextElementSibling;
if (next) row.parentElement!.insertBefore(next, row);
}
});
}
// Live-refresh playlist cards when the engine reports a state change
// (start / advance / natural-completion stop) so the running indicator and
// Start/Stop button stay accurate across clients and after a playlist ends.
document.addEventListener('server:playlist_state_changed', () => {
scenePlaylistsCache.invalidate();
_reloadPlaylistsTab();
});
+6
View File
@@ -108,6 +108,12 @@ export type {
ScenePreset,
ScenePresetListResponse,
} from './types/scene-preset.ts';
export type {
PlaylistItem,
ScenePlaylist,
PlaylistRuntimeState,
ScenePlaylistListResponse,
} from './types/scene-playlist.ts';
// ── Sync Clock ────────────────────────────────────────────────
export type { SyncClock, SyncClockListResponse } from './types/sync-clock.ts';
@@ -0,0 +1,43 @@
/**
* Scene playlist shapes — an ordered, timed sequence of scene presets that
* auto-cycles, activating each preset and holding it for its dwell duration.
*/
export interface PlaylistItem {
scene_preset_id: string;
duration_seconds: number;
}
export interface ScenePlaylist {
id: string;
name: string;
description: string;
items: PlaylistItem[];
loop: boolean;
shuffle: boolean;
order: number;
tags: string[];
icon?: string;
icon_color?: string;
is_running?: boolean;
created_at: string;
updated_at: string;
}
export interface PlaylistRuntimeState {
is_running: boolean;
playlist_id: string | null;
playlist_name: string | null;
current_index: number;
item_count: number;
current_preset_id: string | null;
started_at: string | null;
step_started_at: string | null;
step_duration: number;
}
export interface ScenePlaylistListResponse {
playlists: ScenePlaylist[];
count: number;
state: PlaylistRuntimeState;
}
+46
View File
@@ -790,6 +790,7 @@
"device.icon.entity.ha_source": "Home Assistant source",
"device.icon.entity.automation": "Automation",
"device.icon.entity.scene_preset": "Scene preset",
"device.icon.entity.scene_playlist": "Playlist",
"device.icon.entity.sync_clock": "Sync clock",
"device.icon.entity.game_integration": "Game integration",
"device.icon.entity.audio_processing_template": "Audio processing template",
@@ -1351,6 +1352,50 @@
"scenes.error.delete_failed": "Failed to delete scene",
"scenes.cloned": "Scene cloned",
"scenes.error.clone_failed": "Failed to clone scene",
"playlists.title": "Playlists",
"playlists.add": "New Playlist",
"playlists.edit": "Edit Playlist",
"playlists.name": "Name:",
"playlists.name.placeholder": "My Playlist",
"playlists.description": "Description:",
"playlists.description.hint": "Optional description of what this playlist does",
"playlists.section.playback": "Playback",
"playlists.loop": "Loop:",
"playlists.loop.hint": "Restart from the first scene after the last one; off plays through once and stops",
"playlists.shuffle": "Shuffle:",
"playlists.shuffle.hint": "Randomise the scene order at the start of every cycle",
"playlists.scenes": "Scenes:",
"playlists.scenes.hint": "The scene presets this playlist cycles through, each held for its own duration",
"playlists.scenes.add": "Add Scene",
"playlists.scenes_count": "scenes",
"playlists.scene_one": "scene",
"playlists.scene_many": "scenes",
"playlists.items.empty": "No scenes yet — add some below",
"playlists.items.search_placeholder": "Search scenes...",
"playlists.item.duration": "Seconds",
"playlists.item.move_up": "Move up",
"playlists.item.move_down": "Move down",
"playlists.item.missing": "Missing",
"playlists.chip.loop": "Loop",
"playlists.chip.shuffle": "Shuffle",
"playlists.action.start": "Start",
"playlists.action.stop": "Stop",
"playlists.start": "Start playlist",
"playlists.stop": "Stop playlist",
"playlists.status.playing": "Playing",
"playlists.status.stopped": "Stopped",
"playlists.started": "Playlist started",
"playlists.stopped": "Playlist stopped",
"playlists.created": "Playlist created",
"playlists.updated": "Playlist updated",
"playlists.deleted": "Playlist deleted",
"playlists.delete_confirm": "Delete playlist \"{name}\"?",
"playlists.error.name_required": "Name is required",
"playlists.error.save_failed": "Failed to save playlist",
"playlists.error.start_failed": "Failed to start playlist",
"playlists.error.stop_failed": "Failed to stop playlist",
"playlists.error.delete_failed": "Failed to delete playlist",
"playlists.error.no_presets": "Create a scene preset first",
"dashboard.type.led": "LED",
"dashboard.type.kc": "Key Colors",
"aria.close": "Close",
@@ -2690,6 +2735,7 @@
"section.empty.cspt": "No CSS processing templates yet. Click + to add one.",
"section.empty.automations": "No automations yet. Click + to add one.",
"section.empty.scenes": "No scene presets yet. Click + to add one.",
"section.empty.playlists": "No playlists yet. Click + to add one.",
"bulk.select": "Select",
"bulk.cancel": "Cancel",
"bulk.selected_count.one": "{count} selected",
+46
View File
@@ -847,6 +847,7 @@
"device.icon.entity.ha_source": "Источник Home Assistant",
"device.icon.entity.automation": "Автоматизация",
"device.icon.entity.scene_preset": "Сцена",
"device.icon.entity.scene_playlist": "Плейлист",
"device.icon.entity.sync_clock": "Часы синхронизации",
"device.icon.entity.game_integration": "Игровая интеграция",
"device.icon.entity.audio_processing_template": "Шаблон обработки аудио",
@@ -1385,6 +1386,50 @@
"scenes.error.delete_failed": "Не удалось удалить сцену",
"scenes.cloned": "Сцена клонирована",
"scenes.error.clone_failed": "Не удалось клонировать сцену",
"playlists.title": "Плейлисты",
"playlists.add": "Новый плейлист",
"playlists.edit": "Изменить плейлист",
"playlists.name": "Название:",
"playlists.name.placeholder": "Мой плейлист",
"playlists.description": "Описание:",
"playlists.description.hint": "Необязательное описание плейлиста",
"playlists.section.playback": "Воспроизведение",
"playlists.loop": "Зацикливание:",
"playlists.loop.hint": "Начинать заново с первой сцены после последней; если выключено — проиграть один раз и остановиться",
"playlists.shuffle": "Перемешивание:",
"playlists.shuffle.hint": "Случайный порядок сцен в начале каждого цикла",
"playlists.scenes": "Сцены:",
"playlists.scenes.hint": "Пресеты сцен, которые перебирает плейлист, каждая удерживается своё время",
"playlists.scenes.add": "Добавить сцену",
"playlists.scenes_count": "сцен",
"playlists.scene_one": "сцена",
"playlists.scene_many": "сцен",
"playlists.items.empty": "Сцен пока нет — добавьте ниже",
"playlists.items.search_placeholder": "Поиск сцен...",
"playlists.item.duration": "Секунды",
"playlists.item.move_up": "Вверх",
"playlists.item.move_down": "Вниз",
"playlists.item.missing": "Отсутствует",
"playlists.chip.loop": "Цикл",
"playlists.chip.shuffle": "Перемешать",
"playlists.action.start": "Запустить",
"playlists.action.stop": "Остановить",
"playlists.start": "Запустить плейлист",
"playlists.stop": "Остановить плейлист",
"playlists.status.playing": "Воспроизводится",
"playlists.status.stopped": "Остановлен",
"playlists.started": "Плейлист запущен",
"playlists.stopped": "Плейлист остановлен",
"playlists.created": "Плейлист создан",
"playlists.updated": "Плейлист обновлён",
"playlists.deleted": "Плейлист удалён",
"playlists.delete_confirm": "Удалить плейлист «{name}»?",
"playlists.error.name_required": "Требуется название",
"playlists.error.save_failed": "Не удалось сохранить плейлист",
"playlists.error.start_failed": "Не удалось запустить плейлист",
"playlists.error.stop_failed": "Не удалось остановить плейлист",
"playlists.error.delete_failed": "Не удалось удалить плейлист",
"playlists.error.no_presets": "Сначала создайте пресет сцены",
"dashboard.type.led": "LED",
"dashboard.type.kc": "Цвета клавиш",
"aria.close": "Закрыть",
@@ -2371,6 +2416,7 @@
"section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.",
"section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.",
"section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления.",
"section.empty.playlists": "Плейлистов пока нет. Нажмите + для добавления.",
"bulk.select": "Выбрать",
"bulk.cancel": "Отмена",
"bulk.selected_count.one": "{count} выбран",
+46
View File
@@ -843,6 +843,7 @@
"device.icon.entity.ha_source": "Home Assistant 源",
"device.icon.entity.automation": "自动化",
"device.icon.entity.scene_preset": "场景预设",
"device.icon.entity.scene_playlist": "播放列表",
"device.icon.entity.sync_clock": "同步时钟",
"device.icon.entity.game_integration": "游戏集成",
"device.icon.entity.audio_processing_template": "音频处理模板",
@@ -1381,6 +1382,50 @@
"scenes.error.delete_failed": "删除场景失败",
"scenes.cloned": "场景已克隆",
"scenes.error.clone_failed": "克隆场景失败",
"playlists.title": "播放列表",
"playlists.add": "新建播放列表",
"playlists.edit": "编辑播放列表",
"playlists.name": "名称:",
"playlists.name.placeholder": "我的播放列表",
"playlists.description": "描述:",
"playlists.description.hint": "此播放列表的可选描述",
"playlists.section.playback": "播放",
"playlists.loop": "循环:",
"playlists.loop.hint": "最后一个场景结束后从第一个重新开始;关闭则播放一遍后停止",
"playlists.shuffle": "随机:",
"playlists.shuffle.hint": "每个循环开始时随机打乱场景顺序",
"playlists.scenes": "场景:",
"playlists.scenes.hint": "此播放列表循环的场景预设,每个按各自的时长保持",
"playlists.scenes.add": "添加场景",
"playlists.scenes_count": "个场景",
"playlists.scene_one": "个场景",
"playlists.scene_many": "个场景",
"playlists.items.empty": "还没有场景 — 在下方添加",
"playlists.items.search_placeholder": "搜索场景...",
"playlists.item.duration": "秒",
"playlists.item.move_up": "上移",
"playlists.item.move_down": "下移",
"playlists.item.missing": "缺失",
"playlists.chip.loop": "循环",
"playlists.chip.shuffle": "随机",
"playlists.action.start": "开始",
"playlists.action.stop": "停止",
"playlists.start": "开始播放列表",
"playlists.stop": "停止播放列表",
"playlists.status.playing": "播放中",
"playlists.status.stopped": "已停止",
"playlists.started": "播放列表已开始",
"playlists.stopped": "播放列表已停止",
"playlists.created": "播放列表已创建",
"playlists.updated": "播放列表已更新",
"playlists.deleted": "播放列表已删除",
"playlists.delete_confirm": "删除播放列表“{name}”?",
"playlists.error.name_required": "需要名称",
"playlists.error.save_failed": "保存播放列表失败",
"playlists.error.start_failed": "启动播放列表失败",
"playlists.error.stop_failed": "停止播放列表失败",
"playlists.error.delete_failed": "删除播放列表失败",
"playlists.error.no_presets": "请先创建场景预设",
"dashboard.type.led": "LED",
"dashboard.type.kc": "关键颜色",
"aria.close": "关闭",
@@ -2367,6 +2412,7 @@
"section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。",
"section.empty.automations": "暂无自动化。点击 + 添加。",
"section.empty.scenes": "暂无场景预设。点击 + 添加。",
"section.empty.playlists": "暂无播放列表。点击 + 添加。",
"bulk.select": "选择",
"bulk.cancel": "取消",
"bulk.selected_count.one": "已选 {count} 项",
+1
View File
@@ -50,6 +50,7 @@ _ENTITY_TABLES = [
"value_sources",
"automations",
"scene_presets",
"scene_playlists",
"sync_clocks",
"color_strip_processing_templates",
"gradients",
@@ -0,0 +1,118 @@
"""Scene playlist data models — an ordered, timed sequence of scene presets.
A playlist auto-cycles through its items, activating each referenced scene
preset and holding it for the item's dwell duration before advancing. The
runtime that drives the cycling lives in
``core/scenes/playlist_engine.py``; this module only describes the persisted
shape.
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import List
# Dwell-duration guard rails (seconds). A floor avoids a busy-loop when a
# user fat-fingers ``0``; the ceiling is just a sane upper bound for a UI.
MIN_DURATION_SECONDS = 1.0
MAX_DURATION_SECONDS = 86_400.0 # 24h
DEFAULT_DURATION_SECONDS = 30.0
def clamp_duration(value: float) -> float:
"""Clamp a dwell duration into ``[MIN, MAX]`` seconds.
Coerces non-numeric/None input to the default rather than raising, so a
malformed persisted value can never wedge the cycling loop.
"""
try:
seconds = float(value)
except (TypeError, ValueError):
return DEFAULT_DURATION_SECONDS
if seconds < MIN_DURATION_SECONDS:
return MIN_DURATION_SECONDS
if seconds > MAX_DURATION_SECONDS:
return MAX_DURATION_SECONDS
return seconds
@dataclass
class PlaylistItem:
"""One step in a playlist: a scene preset held for ``duration_seconds``."""
scene_preset_id: str
duration_seconds: float = DEFAULT_DURATION_SECONDS
def to_dict(self) -> dict:
return {
"scene_preset_id": self.scene_preset_id,
"duration_seconds": self.duration_seconds,
}
@classmethod
def from_dict(cls, data: dict) -> "PlaylistItem":
return cls(
scene_preset_id=data["scene_preset_id"],
duration_seconds=clamp_duration(data.get("duration_seconds", DEFAULT_DURATION_SECONDS)),
)
@dataclass
class ScenePlaylist:
"""A named, ordered sequence of scene presets that auto-cycles."""
id: str
name: str
description: str = ""
items: List[PlaylistItem] = field(default_factory=list)
# When True, restart from the first item after the last one finishes.
# When False, the playlist stops (and leaves the last scene applied).
loop: bool = True
# When True, the item order is shuffled at the start of every cycle.
shuffle: bool = False
tags: List[str] = field(default_factory=list)
# Custom card icon (frontend display only)
icon: str = ""
icon_color: str = ""
order: int = 0
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
def to_dict(self) -> dict:
d = {
"id": self.id,
"name": self.name,
"description": self.description,
"items": [i.to_dict() for i in self.items],
"loop": self.loop,
"shuffle": self.shuffle,
"tags": self.tags,
"order": self.order,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@classmethod
def from_dict(cls, data: dict) -> "ScenePlaylist":
return cls(
id=data["id"],
name=data["name"],
description=data.get("description", ""),
items=[PlaylistItem.from_dict(i) for i in data.get("items", [])],
loop=data.get("loop", True),
shuffle=data.get("shuffle", False),
tags=data.get("tags", []),
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
order=data.get("order", 0),
created_at=datetime.fromisoformat(
data.get("created_at", datetime.now(timezone.utc).isoformat())
),
updated_at=datetime.fromisoformat(
data.get("updated_at", datetime.now(timezone.utc).isoformat())
),
)
@@ -0,0 +1,82 @@
"""Scene playlist storage using SQLite."""
from datetime import datetime, timezone
from typing import List
from ledgrab.storage.base_sqlite_store import BaseSqliteStore
from ledgrab.storage.database import Database
from ledgrab.storage.scene_playlist import PlaylistItem, ScenePlaylist
from ledgrab.utils import get_logger
logger = get_logger(__name__)
class ScenePlaylistStore(BaseSqliteStore[ScenePlaylist]):
"""Persistent storage for scene playlists."""
_table_name = "scene_playlists"
_entity_name = "Scene playlist"
_cloneable = True
def __init__(self, db: Database):
super().__init__(db, ScenePlaylist.from_dict)
# Backward-compatible aliases
get_playlist = BaseSqliteStore.get
delete_playlist = BaseSqliteStore.delete
def get_all_playlists(self) -> List[ScenePlaylist]:
"""Get all playlists sorted by order field."""
return sorted(self._items.values(), key=lambda p: p.order)
# Override get_all to also sort by order for consistency
def get_all(self) -> List[ScenePlaylist]:
return self.get_all_playlists()
def create_playlist(self, playlist: ScenePlaylist) -> ScenePlaylist:
self._check_name_unique(playlist.name)
self._items[playlist.id] = playlist
self._save_item(playlist.id, playlist)
logger.info(f"Created scene playlist: {playlist.name} ({playlist.id})")
return playlist
def update_playlist(
self,
playlist_id: str,
name: str | None = None,
description: str | None = None,
items: List[PlaylistItem] | None = None,
loop: bool | None = None,
shuffle: bool | None = None,
order: int | None = None,
tags: List[str] | None = None,
icon: str | None = None,
icon_color: str | None = None,
) -> ScenePlaylist:
playlist = self.get(playlist_id)
if name is not None:
self._check_name_unique(name, exclude_id=playlist_id)
playlist.name = name
if description is not None:
playlist.description = description
if items is not None:
playlist.items = items
if loop is not None:
playlist.loop = loop
if shuffle is not None:
playlist.shuffle = shuffle
if order is not None:
playlist.order = order
if tags is not None:
playlist.tags = tags
if icon is not None:
playlist.icon = icon or ""
if icon_color is not None:
playlist.icon_color = icon_color or ""
playlist.updated_at = datetime.now(timezone.utc)
self._save_item(playlist_id, playlist)
logger.info(f"Updated scene playlist: {playlist_id}")
return playlist
+1
View File
@@ -246,6 +246,7 @@
{% include 'modals/cspt-modal.html' %}
{% include 'modals/automation-editor.html' %}
{% include 'modals/scene-preset-editor.html' %}
{% include 'modals/scene-playlist-editor.html' %}
{% include 'modals/audio-source-editor.html' %}
{% include 'modals/test-audio-source.html' %}
{% include 'modals/audio-template.html' %}
@@ -0,0 +1,107 @@
<!-- Scene Playlist Editor Modal — ordered, timed sequence of scene presets.
Mirrors the scene-preset / automation editor rack-panel vocabulary:
.ds-section wrappers carry the channel accent (signal = identity,
amber = playback, cyan = scenes). Inner element IDs are consumed by
scene-playlists.ts. -->
<div id="playlist-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="playlist-editor-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="playlist-editor-title"><svg class="icon" viewBox="0 0 24 24"><path d="M21 15V6"/><path d="M18.5 18a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5"/><path d="M12 12H3"/><path d="M16 6H3"/><path d="M12 18H3"/></svg> <span data-i18n="playlists.add">New Playlist</span></h2>
<button class="modal-close-btn" onclick="closePlaylistEditor()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="playlist-editor-form">
<input type="hidden" id="playlist-editor-id">
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="playlist-editor-name" data-i18n="playlists.name">Name:</label>
<input type="text" id="playlist-editor-name" data-i18n-placeholder="playlists.name.placeholder" placeholder="My Playlist" required>
<div id="playlist-tags-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="playlist-editor-description" data-i18n="playlists.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="playlists.description.hint">Optional description of what this playlist does</small>
<input type="text" id="playlist-editor-description">
</div>
</div>
</section>
<!-- ── 02 · PLAYBACK ───────────────────────────────── -->
<section class="ds-section" data-ds-key="playback" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="playlists.section.playback">Playback</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="label-row">
<label for="playlist-editor-loop" data-i18n="playlists.loop">Loop:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="playlists.loop.hint">Restart from the first scene after the last one; off plays through once and stops</small>
</div>
<label class="settings-toggle">
<input type="checkbox" id="playlist-editor-loop" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="label-row">
<label for="playlist-editor-shuffle" data-i18n="playlists.shuffle">Shuffle:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="playlists.shuffle.hint">Randomise the scene order at the start of every cycle</small>
</div>
<label class="settings-toggle">
<input type="checkbox" id="playlist-editor-shuffle">
<span class="settings-toggle-slider"></span>
</label>
</div>
</div>
</section>
<!-- ── 03 · SCENES ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="scenes" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="playlists.scenes">Scenes</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label data-i18n="playlists.scenes">Scenes:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="playlists.scenes.hint">The scene presets this playlist cycles through, each held for its own duration</small>
<div id="playlist-item-list" class="playlist-item-list" data-empty="No scenes yet"></div>
<button type="button" id="playlist-item-add-btn" class="scene-target-add-slot" onclick="addPlaylistItem()"><span data-i18n="playlists.scenes.add">Add Scene</span></button>
</div>
</div>
</section>
<div id="playlist-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closePlaylistEditor()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="savePlaylist()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>