Files
led2/lightsync/api/ws.py
Claude 5e400a6834 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
2026-04-06 13:07:18 +00:00

86 lines
2.9 KiB
Python

import asyncio
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
router = APIRouter()
class ConnectionManager:
def __init__(self):
self.active_connections: list[WebSocket] = []
async def connect(self, ws: WebSocket) -> None:
await ws.accept()
self.active_connections.append(ws)
def disconnect(self, ws: WebSocket) -> None:
if ws in self.active_connections:
self.active_connections.remove(ws)
async def broadcast(self, message: dict) -> None:
dead = []
for connection in self.active_connections:
try:
await connection.send_text(json.dumps(message))
except Exception:
dead.append(connection)
for d in dead:
if d in self.active_connections:
self.active_connections.remove(d)
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)
try:
while True:
data = await websocket.receive_text()
msg = json.loads(data)
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)