From 5e400a68341a5b520d660db31edbc7921caeaece Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 13:07:18 +0000 Subject: [PATCH] feat(02-02): wire WebSocket broadcast loop and command dispatch - Add broadcast_loop sending position ticks at 10Hz to all clients - Replace echo stub with play/pause/seek/load command dispatch to MPVEngine - Start broadcast_loop as asyncio task in lifespan, cancel on shutdown - Use get_engine callable to avoid circular import at module level --- lightsync/api/ws.py | 46 ++++++++++++++++++++++++++++++++++++++++++--- lightsync/main.py | 8 ++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/lightsync/api/ws.py b/lightsync/api/ws.py index 9d1cbf8..c22557a 100644 --- a/lightsync/api/ws.py +++ b/lightsync/api/ws.py @@ -1,3 +1,4 @@ +import asyncio import json from fastapi import APIRouter, WebSocket, WebSocketDisconnect @@ -31,6 +32,20 @@ class ConnectionManager: manager = ConnectionManager() +async def broadcast_loop(get_engine) -> None: + """Broadcast playback state to all WebSocket clients at 10Hz (AUD-04). + + Args: + get_engine: callable returning the MPVEngine instance (avoids import cycle) + """ + while True: + await asyncio.sleep(0.1) + engine = get_engine() + if engine and manager.active_connections: + state = engine.get_state() + await manager.broadcast({"type": "tick", **state}) + + @router.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): await manager.connect(websocket) @@ -38,8 +53,33 @@ async def websocket_endpoint(websocket: WebSocket): while True: data = await websocket.receive_text() msg = json.loads(data) - # Phase 2 will dispatch msg["type"] to handlers - # For now: echo back with type=ack - await websocket.send_text(json.dumps({"type": "ack", "echo": msg})) + msg_type = msg.get("type") + + # Import engine reference from main (module-level var) + from lightsync.main import engine + + if engine is None: + await websocket.send_text(json.dumps({"type": "error", "message": "engine not ready"})) + continue + + if msg_type == "play": + engine.play() + await websocket.send_text(json.dumps({"type": "ack", "command": "play"})) + elif msg_type == "pause": + engine.pause() + await websocket.send_text(json.dumps({"type": "ack", "command": "pause"})) + elif msg_type == "seek": + position = float(msg.get("position", 0)) + engine.seek(position) + await websocket.send_text(json.dumps({"type": "ack", "command": "seek", "position": position})) + elif msg_type == "load": + path = msg.get("path", "") + if path: + engine.load(path) + await websocket.send_text(json.dumps({"type": "ack", "command": "load", "path": path})) + else: + await websocket.send_text(json.dumps({"type": "error", "message": "missing path"})) + else: + await websocket.send_text(json.dumps({"type": "ack", "echo": msg})) except WebSocketDisconnect: manager.disconnect(websocket) diff --git a/lightsync/main.py b/lightsync/main.py index 8b3dba0..5dbbd00 100644 --- a/lightsync/main.py +++ b/lightsync/main.py @@ -1,3 +1,4 @@ +import asyncio import os from contextlib import asynccontextmanager from pathlib import Path @@ -27,8 +28,15 @@ async def lifespan(app: FastAPI): engine = MPVEngine(ao=ao) engine.start() app.state.engine = engine + from lightsync.api.ws import broadcast_loop + task = asyncio.create_task(broadcast_loop(lambda: engine)) yield # Shutdown + task.cancel() + try: + await task + except asyncio.CancelledError: + pass engine.stop() await registry.save()