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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user