From 3d36cc57543b46bb190502532f8bfbd47d5eda9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 10:10:23 +0000 Subject: [PATCH] feat(05-01): add per-connection show state and load handler to ws.py - Import encode_animation_cmd and ShowModel at module level - Add show, fired_ids, is_playing per-connection state variables - Extend load handler to load show from show_store by show_id (D-04) - play handler sets is_playing=True - pause handler sets is_playing=False without touching fired_ids - seek handler rebuilds fired_ids from show cues before seek position (D-03) --- lightsync/api/ws.py | 45 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/lightsync/api/ws.py b/lightsync/api/ws.py index 7138a43..4ca45b5 100644 --- a/lightsync/api/ws.py +++ b/lightsync/api/ws.py @@ -1,17 +1,20 @@ """WebSocket hub — browser-driven tick/beat protocol (Phase 02-03 redesign). Browser sends position ticks at ~10Hz; server looks up the next beat and -sends a beat notification back when one is near. +sends a beat notification back when one is near. Phase 5 adds cue scheduling: +the tick handler fires UDP animation commands at correct timestamps. Protocol (browser → server): {"type": "tick", "position": 42.3} — audio playback position - {"type": "play"} — playback started (log only for now) - {"type": "pause"} — playback paused (log only for now) - {"type": "seek", "position": 42.3} — seek event (log only for now) + {"type": "play", "position": 42.3} — playback started + {"type": "pause", "position": 42.3} — playback paused + {"type": "seek", "position": 42.3} — seek event + {"type": "load", "path": "...", "show_id": "uuid"} — file + show loaded Protocol (server → browser): - {"type": "beat", "position": 42.3, "tempo": 120.0} — beat detected near position - {"type": "error", "message": "..."} — error response + {"type": "beat", "position": 42.3, "tempo": 120.0} — beat detected near position + {"type": "preview_update", "device_id": "uuid", "animation": "...", "color": [...]} + {"type": "error", "message": "..."} — error response """ from __future__ import annotations @@ -19,6 +22,8 @@ import bisect import json import logging from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from lightsync.protocol.animation_cmd import encode_animation_cmd +from lightsync.models.show import ShowModel logger = logging.getLogger(__name__) @@ -81,6 +86,11 @@ async def websocket_endpoint(websocket: WebSocket): tempo: float = 0.0 last_beat_reported: float | None = None # avoid re-reporting same beat + # Per-connection cue scheduler state (Phase 5 — D-04) + show: ShowModel | None = None # loaded show for cue scheduling + fired_ids: set[str] = set() # cues already dispatched this playback pass + is_playing: bool = False # gate cue scheduling on play state + try: while True: data = await websocket.receive_text() @@ -122,14 +132,24 @@ async def websocket_endpoint(websocket: WebSocket): elif msg_type == "play": logger.info("[ws] play event at position=%.3f", float(msg.get("position", 0))) last_beat_reported = None # reset beat tracking on play + is_playing = True # enable cue scheduling elif msg_type == "pause": logger.info("[ws] pause event at position=%.3f", float(msg.get("position", 0))) + is_playing = False # disable cue scheduling; do NOT modify fired_ids elif msg_type == "seek": position = float(msg.get("position", 0.0)) logger.info("[ws] seek to position=%.3f", position) last_beat_reported = None # reset so beats near seek target fire + # Rebuild fired_ids: mark everything before seek position as fired (D-03) + if show is not None: + fired_ids = { + str(cue.id) + for track in show.tracks + for cue in track.cues + if cue.timestamp < position - 0.060 + } elif msg_type == "load": # Browser signals which file is now playing — update beat cache lookup @@ -146,6 +166,19 @@ async def websocket_endpoint(websocket: WebSocket): else: logger.warning("[ws] no beat data cached for %s", path) + # Show cue list loading (Phase 5 — D-04) + show_id = msg.get("show_id") + if show_id: + import lightsync.main as _main + loaded = await _main.show_store.load(show_id) + if loaded: + show = loaded + fired_ids = set() + is_playing = False + logger.info("[ws] loaded show %s with %d tracks", show_id, len(show.tracks)) + else: + logger.warning("[ws] show %s not found", show_id) + else: # Unknown message type — echo back for debugging await websocket.send_text(json.dumps({"type": "ack", "echo": msg}))