/** The live connection to the backend. State arrives over a websocket that only pushes: every command goes out as a plain POST. A reconnect reseeds from `GET /api/state` rather than assuming the UI's copy is still right, and `onLibraryChanged` fires when a rescan has finished. */ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../api/client"; import type { PlayerState, ServerMessage } from "../api/types"; const RECONNECT_DELAY_MS = 1500; export interface Connection { state: PlayerState | null; online: boolean; /** Apply a change locally so a keypress feels instant; the next frame reconciles. * * A plain whole-object patch is enough here because state only ever arrives pushed * over the websocket - there's no in-flight poll that could resolve late and stomp * on it. `useHomeAssistant`'s `optimistic()` solves the same problem for a *polled* * source, where that race is real, with a per-entity timestamp guard instead. */ optimistic: (patch: Partial) => void; } export function usePlayerState(onLibraryChanged: () => void): Connection { const [state, setState] = useState(null); const [online, setOnline] = useState(false); // Kept in a ref so reconnecting never re-runs the effect that owns the socket. const libraryChanged = useRef(onLibraryChanged); libraryChanged.current = onLibraryChanged; useEffect(() => { let socket: WebSocket | null = null; let retry: ReturnType | undefined; let closed = false; const connect = () => { const protocol = location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(`${protocol}//${location.host}/api/ws`); socket = ws; socket.onopen = () => { setOnline(true); // The server sends a snapshot on connect, but a reconnect may have missed // changes in between, so ask for the truth as well. Guarded against a slow // response landing after a *later* reconnect already replaced this socket - // every other fetch-on-mount hook in this codebase guards the same way. void api .state() .then((fetched) => { if (!closed && socket === ws) setState(fetched); }) .catch(() => undefined); }; socket.onmessage = (event) => { const message = JSON.parse(event.data as string) as ServerMessage; if (message.type === "state") { setState(message.state); } else if (message.type === "position") { setState((previous) => previous ? { ...previous, position: message.position, duration: message.duration } : previous, ); } else if (message.type === "library") { libraryChanged.current(); } }; const reconnect = () => { setOnline(false); if (!closed) retry = setTimeout(connect, RECONNECT_DELAY_MS); }; socket.onclose = reconnect; socket.onerror = () => socket?.close(); }; connect(); return () => { closed = true; clearTimeout(retry); socket?.close(); }; }, []); const optimistic = useCallback((patch: Partial) => { setState((previous) => (previous ? { ...previous, ...patch } : previous)); }, []); return { state, online, optimistic }; }