Files
musicmouse/web/src/hooks/usePlayerState.ts

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 };
}