from __future__ import annotations import json import threading from collections.abc import Callable import websocket class SyncClient: def __init__( self, on_event: Callable[[str, dict], None], on_error: Callable[[str], None], ) -> None: self.on_event = on_event self.on_error = on_error self.ws: websocket.WebSocketApp | None = None self.thread: threading.Thread | None = None def connect(self, server_base_url: str, room_code: str, device_id: str) -> None: self.close() ws_base = ( server_base_url.rstrip("/") .replace("https://", "wss://") .replace("http://", "ws://") ) url = f"{ws_base}/ws?roomCode={room_code}&deviceId={device_id}" self.ws = websocket.WebSocketApp( url, on_message=self._on_message, on_error=lambda _ws, err: self.on_error(str(err)), on_close=lambda _ws, _code, reason: self.on_error(f"websocket closed: {reason}"), ) self.thread = threading.Thread(target=self.ws.run_forever, daemon=True) self.thread.start() def close(self) -> None: if self.ws: self.ws.close() self.ws = None def set_source(self, payload: dict) -> None: self._send("setSource", payload) def play(self, position_ms: int) -> None: self._send("play", {"positionMs": position_ms}) def pause(self, position_ms: int) -> None: self._send("pause", {"positionMs": position_ms}) def seek(self, position_ms: int) -> None: self._send("seek", {"state": "playing", "positionMs": position_ms}) def sync_to_live(self) -> None: self._send("syncToLive", {"state": "playing", "positionMs": 0, "targetLatencyMs": 3000}) def _send(self, event_type: str, payload: dict) -> None: if self.ws: self.ws.send(json.dumps({"type": event_type, "payload": payload})) def _on_message(self, _ws: websocket.WebSocketApp, message: str) -> None: data = json.loads(message) self.on_event(data.get("type", ""), data.get("payload") or {})