import { get, writable } from "svelte/store"; export enum LCMLiveStatus { CONNECTED = "connected", DISCONNECTED = "disconnected", CONNECTING = "connecting", WAIT = "wait", SEND_FRAME = "send_frame", TIMEOUT = "timeout", ERROR = "error", } const initStatus: LCMLiveStatus = LCMLiveStatus.DISCONNECTED; export const lcmLiveStatus = writable(initStatus); export const streamId = writable(null); // WebSocket connection let websocket: WebSocket; // Register browser unload event listener to properly close WebSockets if (typeof window !== "undefined") { window.addEventListener("beforeunload", () => { // Close the WebSocket properly if it exists if (websocket && websocket.readyState === WebSocket.OPEN) { websocket.close(1000, "Page unload"); } }); } export const lcmLiveActions = { async start( getSreamdata: () => | [Record] | [Record, Blob], ) { return new Promise((resolve, reject) => { try { // Set connecting status immediately lcmLiveStatus.set(LCMLiveStatus.CONNECTING); const userId = crypto.randomUUID(); const websocketURL = `${ window.location.protocol === "https:" ? "wss" : "ws" }:${window.location.host}/api/ws/${userId}`; // Close any existing connection first if (websocket && websocket.readyState !== WebSocket.CLOSED) { websocket.close(); } websocket = new WebSocket(websocketURL); // Set a connection timeout const connectionTimeout = setTimeout(() => { if (websocket && websocket.readyState !== WebSocket.OPEN) { console.error("WebSocket connection timeout"); lcmLiveStatus.set(LCMLiveStatus.ERROR); streamId.set(null); reject(new Error("Connection timeout. Please try again.")); websocket.close(); } }, 10000); // 10 second timeout websocket.onopen = () => { clearTimeout(connectionTimeout); console.log("Connected to websocket"); }; websocket.onclose = (event) => { clearTimeout(connectionTimeout); console.log( `Disconnected from websocket: ${event.code} ${event.reason}`, ); // Only change status if we're not in ERROR state (which would mean we already handled the error) if (get(lcmLiveStatus) !== LCMLiveStatus.ERROR) { lcmLiveStatus.set(LCMLiveStatus.DISCONNECTED); } // If connection was never established (close without open) if (event.code === 1006 && get(streamId) === null) { reject( new Error("Cannot connect to server. Please try again later."), ); } }; websocket.onerror = (err) => { clearTimeout(connectionTimeout); console.error("WebSocket error:", err); lcmLiveStatus.set(LCMLiveStatus.ERROR); streamId.set(null); reject(new Error("Connection error. Please try again.")); }; websocket.onmessage = (event) => { try { const data = JSON.parse(event.data); switch (data.status) { case "connected": lcmLiveStatus.set(LCMLiveStatus.CONNECTED); streamId.set(userId); resolve({ status: "connected", userId }); break; case "send_frame": lcmLiveStatus.set(LCMLiveStatus.SEND_FRAME); try { const streamData = getSreamdata(); // Send as an object, not a string, to use the proper handling in the send method this.send({ status: "next_frame" }); for (const d of streamData) { this.send(d); } } catch (error) { console.error("Error sending frame data:", error); } break; case "wait": lcmLiveStatus.set(LCMLiveStatus.WAIT); break; case "timeout": console.log("Session timeout"); lcmLiveStatus.set(LCMLiveStatus.TIMEOUT); streamId.set(null); reject(new Error("Session timeout. Please restart.")); break; case "error": console.error("Server error:", data.message); lcmLiveStatus.set(LCMLiveStatus.ERROR); streamId.set(null); reject(new Error(data.message || "Server error occurred")); break; default: console.log("Unknown message status:", data.status); } } catch (error) { console.error("Error handling websocket message:", error); } }; } catch (err) { console.error("Error initializing websocket:", err); lcmLiveStatus.set(LCMLiveStatus.ERROR); streamId.set(null); reject(err); } }); }, send(data: Blob | Record) { try { if (websocket && websocket.readyState === WebSocket.OPEN) { if (data instanceof Blob) { websocket.send(data); } else { websocket.send(JSON.stringify(data)); } } else { const readyStateText = websocket ? ["CONNECTING", "OPEN", "CLOSING", "CLOSED"][websocket.readyState] : "null"; console.warn(`WebSocket not ready for sending: ${readyStateText}`); // If WebSocket is closed unexpectedly, set status to disconnected if (!websocket || websocket.readyState === WebSocket.CLOSED) { lcmLiveStatus.set(LCMLiveStatus.DISCONNECTED); streamId.set(null); } } } catch (error) { console.error("Error sending data through WebSocket:", error); // Handle WebSocket error by forcing disconnection this.stop(); } }, async reconnect( getSreamdata: () => | [Record] | [Record, Blob], ) { try { await this.stop(); // Small delay to ensure clean disconnection before reconnecting await new Promise((resolve) => setTimeout(resolve, 500)); return await this.start(getSreamdata); } catch (error) { console.error("Reconnection failed:", error); throw error; } }, async stop() { lcmLiveStatus.set(LCMLiveStatus.DISCONNECTED); try { if (websocket) { // Only attempt to close if not already closed if (websocket.readyState !== WebSocket.CLOSED) { // Set up onclose handler to clean up only websocket.onclose = () => { console.log("WebSocket closed cleanly during stop()"); }; // Set up onerror to be silent during intentional closure websocket.onerror = () => {}; websocket.close(1000, "Client initiated disconnect"); } } } catch (error) { console.error("Error during WebSocket closure:", error); } finally { // Always clean up references websocket = null; streamId.set(null); } }, };