Web frontend
This commit is contained in:
40
web/src/hooks/useGridColumns.ts
Normal file
40
web/src/hooks/useGridColumns.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/** How many columns the album grid actually has.
|
||||
|
||||
Arrow-key navigation has to move a whole row at a time, and the grid is
|
||||
`repeat(auto-fill, minmax(180px, 1fr))` - so the count is whatever the browser worked
|
||||
out, not something the code can assume.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useGridColumns(): [(element: HTMLElement | null) => void, number] {
|
||||
const [columns, setColumns] = useState(4);
|
||||
const element = useRef<HTMLElement | null>(null);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const grid = element.current;
|
||||
if (!grid) return;
|
||||
const count = getComputedStyle(grid).gridTemplateColumns.split(" ").filter(Boolean).length;
|
||||
if (count) setColumns((previous) => (count === previous ? previous : count));
|
||||
}, []);
|
||||
|
||||
const ref = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
element.current = node;
|
||||
if (node) requestAnimationFrame(measure);
|
||||
},
|
||||
[measure],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(measure);
|
||||
if (element.current) observer.observe(element.current);
|
||||
window.addEventListener("resize", measure);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", measure);
|
||||
};
|
||||
}, [measure]);
|
||||
|
||||
return [ref, columns];
|
||||
}
|
||||
38
web/src/hooks/useLibrary.ts
Normal file
38
web/src/hooks/useLibrary.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/** The whole album index, fetched once.
|
||||
|
||||
At roughly 90 kB for a real collection this is cheaper than a search endpoint and makes
|
||||
type-to-search instant, which is what the keyboard-first design needs.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { Album } from "../api/types";
|
||||
|
||||
export interface Library {
|
||||
albums: Album[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export function useLibrary(): Library {
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
api
|
||||
.library()
|
||||
.then((loaded) => {
|
||||
setAlbums(loaded);
|
||||
setError(null);
|
||||
})
|
||||
.catch((cause: unknown) => setError(String(cause)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(reload, [reload]);
|
||||
|
||||
return { albums, loading, error, reload };
|
||||
}
|
||||
33
web/src/hooks/usePlaybackClock.ts
Normal file
33
web/src/hooks/usePlaybackClock.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/** A smooth playback position, from a position that only arrives twice a second.
|
||||
|
||||
Driving a progress bar straight off the websocket visibly steps. This interpolates
|
||||
between frames on every animation frame and re-seeds whenever a real one lands, which
|
||||
also gives beat-synced animation the frame-accurate clock it will need later.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function usePlaybackClock(position: number, playing: boolean): number {
|
||||
const [interpolated, setInterpolated] = useState(position);
|
||||
const anchor = useRef({ position, at: performance.now() });
|
||||
|
||||
// A new server frame is the truth; restart the interpolation from it.
|
||||
useEffect(() => {
|
||||
anchor.current = { position, at: performance.now() };
|
||||
setInterpolated(position);
|
||||
}, [position]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
let frame = 0;
|
||||
const tick = () => {
|
||||
const { position: base, at } = anchor.current;
|
||||
setInterpolated(base + (performance.now() - at) / 1000);
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [playing]);
|
||||
|
||||
return playing ? interpolated : position;
|
||||
}
|
||||
82
web/src/hooks/usePlayerState.ts
Normal file
82
web/src/hooks/usePlayerState.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/** 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. */
|
||||
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:";
|
||||
socket = new WebSocket(`${protocol}//${location.host}/api/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.
|
||||
void api.state().then(setState).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 };
|
||||
}
|
||||
Reference in New Issue
Block a user