96 lines
3.3 KiB
TypeScript
96 lines
3.3 KiB
TypeScript
/** 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<PlayerState>) => void;
|
|
}
|
|
|
|
export function usePlayerState(onLibraryChanged: () => void): Connection {
|
|
const [state, setState] = useState<PlayerState | null>(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<typeof setTimeout> | 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<PlayerState>) => {
|
|
setState((previous) => (previous ? { ...previous, ...patch } : previous));
|
|
}, []);
|
|
|
|
return { state, online, optimistic };
|
|
}
|