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
This commit is contained in:
Claude
2026-04-06 13:07:18 +00:00
parent 3173fa2228
commit 5e400a6834
2 changed files with 51 additions and 3 deletions

View File

@@ -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
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)

View File

@@ -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()