@ksp-gonogo/kerbcast-react 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ export interface LabelableCamera {
2
+ flightId: number;
3
+ cameraName: string;
4
+ partTitle?: string | null;
5
+ }
6
+ /**
7
+ * Build a labeller closed over the current camera list. The returned function
8
+ * maps a camera to its display name, disambiguating only the cameras whose
9
+ * `cameraName` collides with another in the same list.
10
+ *
11
+ * The label does NOT include the vessel name. Call sites append that (and any
12
+ * " - signal lost" suffix) themselves.
13
+ */
14
+ export declare function buildCameraLabeler<T extends LabelableCamera>(cameras: readonly T[]): (camera: T) => string;
@@ -0,0 +1,59 @@
1
+ /*
2
+ * Camera display labels, shared by every widget that lets the operator pick a
3
+ * camera.
4
+ *
5
+ * Hullcam names cameras non-uniquely: its DockingPortCameraPatch labels EVERY
6
+ * docking-port camera "NavCam", colliding with the dedicated NavCam part, so a
7
+ * raw `cameraName` list shows two indistinguishable "NavCam" rows and the
8
+ * docking-port feed looks missing. When a name is shared by more than one
9
+ * camera in the list, we append the part title (e.g. "NavCam - Clamp-O-Tron
10
+ * Docking Port Jr.") to tell them apart; non-colliding cameras are unchanged.
11
+ *
12
+ * Identical parts defeat that too (nine BoosterCams share both name and part
13
+ * title), so labels that still collide get a "#N" suffix, numbered in
14
+ * ascending flightId order. flightId is stable per part, so the numbers do
15
+ * not shuffle between sessions or when unrelated cameras come and go.
16
+ */
17
+ /**
18
+ * Build a labeller closed over the current camera list. The returned function
19
+ * maps a camera to its display name, disambiguating only the cameras whose
20
+ * `cameraName` collides with another in the same list.
21
+ *
22
+ * The label does NOT include the vessel name. Call sites append that (and any
23
+ * " - signal lost" suffix) themselves.
24
+ */
25
+ export function buildCameraLabeler(cameras) {
26
+ const counts = new Map();
27
+ for (const c of cameras) {
28
+ counts.set(c.cameraName, (counts.get(c.cameraName) ?? 0) + 1);
29
+ }
30
+ const baseLabel = (camera) => (counts.get(camera.cameraName) ?? 0) > 1 &&
31
+ camera.partTitle &&
32
+ camera.partTitle !== camera.cameraName
33
+ ? `${camera.cameraName} - ${camera.partTitle}`
34
+ : camera.cameraName;
35
+ /* Number the cameras whose base label still collides: per colliding label,
36
+ sort by flightId and hand out #1..#N. */
37
+ const byLabel = new Map();
38
+ for (const c of cameras) {
39
+ const label = baseLabel(c);
40
+ const group = byLabel.get(label);
41
+ if (group)
42
+ group.push(c);
43
+ else
44
+ byLabel.set(label, [c]);
45
+ }
46
+ const ordinals = new Map();
47
+ for (const group of byLabel.values()) {
48
+ if (group.length < 2)
49
+ continue;
50
+ [...group]
51
+ .sort((a, b) => a.flightId - b.flightId)
52
+ .forEach((c, i) => ordinals.set(c.flightId, i + 1));
53
+ }
54
+ return (camera) => {
55
+ const label = baseLabel(camera);
56
+ const ordinal = ordinals.get(camera.flightId);
57
+ return ordinal === undefined ? label : `${label} #${ordinal}`;
58
+ };
59
+ }
@@ -0,0 +1,34 @@
1
+ import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
2
+ import { type ReactNode } from "react";
3
+ /**
4
+ * Lifecycle controller for per-camera slot subscriptions. The default
5
+ * implementation refcounts so multiple widgets sharing the same flightId
6
+ * share one slot.
7
+ */
8
+ export interface KerbcastSubscriptions {
9
+ acquire(flightId: number): void;
10
+ release(flightId: number): void;
11
+ }
12
+ /** Build the default refcounted subscriptions implementation for a client. */
13
+ export declare function createClientSubscriptions(client: KerbcastClient): KerbcastSubscriptions;
14
+ export interface KerbcastProviderProps {
15
+ client: KerbcastClient;
16
+ /** Override the subscription manager. Defaults to a refcounted per-client impl. */
17
+ subscriptions?: KerbcastSubscriptions;
18
+ children: ReactNode;
19
+ }
20
+ /**
21
+ * Provide a `KerbcastClient` (and optional subscriptions override) to the
22
+ * component subtree. Hooks and `CameraFeed` read both from this context.
23
+ */
24
+ export declare function KerbcastProvider({ client, subscriptions: subscriptionsProp, children, }: KerbcastProviderProps): React.JSX.Element;
25
+ /**
26
+ * Return the `KerbcastClient` from the nearest `KerbcastProvider`.
27
+ * Throws if called outside a provider.
28
+ */
29
+ export declare function useKerbcastClient(): KerbcastClient;
30
+ /**
31
+ * Return the `KerbcastSubscriptions` from the nearest `KerbcastProvider`.
32
+ * Throws if called outside a provider.
33
+ */
34
+ export declare function useKerbcastSubscriptions(): KerbcastSubscriptions;
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useMemo, useRef, } from "react";
3
+ /** Build the default refcounted subscriptions implementation for a client. */
4
+ export function createClientSubscriptions(client) {
5
+ const refcounts = new Map();
6
+ return {
7
+ acquire(flightId) {
8
+ const count = (refcounts.get(flightId) ?? 0) + 1;
9
+ refcounts.set(flightId, count);
10
+ if (count === 1) {
11
+ client.subscribe(flightId).catch(() => { });
12
+ }
13
+ },
14
+ release(flightId) {
15
+ const count = (refcounts.get(flightId) ?? 1) - 1;
16
+ if (count <= 0) {
17
+ refcounts.delete(flightId);
18
+ client.unsubscribe(flightId).catch(() => { });
19
+ }
20
+ else {
21
+ refcounts.set(flightId, count);
22
+ }
23
+ },
24
+ };
25
+ }
26
+ const KerbcastContext = createContext(null);
27
+ /**
28
+ * Provide a `KerbcastClient` (and optional subscriptions override) to the
29
+ * component subtree. Hooks and `CameraFeed` read both from this context.
30
+ */
31
+ export function KerbcastProvider({ client, subscriptions: subscriptionsProp, children, }) {
32
+ // Create default subscriptions once per client. A ref so the object is
33
+ // stable across renders even if the parent re-renders.
34
+ const defaultSubsRef = useRef(null);
35
+ if (defaultSubsRef.current === null) {
36
+ defaultSubsRef.current = createClientSubscriptions(client);
37
+ }
38
+ const value = useMemo(() => ({
39
+ client,
40
+ subscriptions: subscriptionsProp ?? defaultSubsRef.current,
41
+ }),
42
+ // eslint-disable-next-line react-hooks/exhaustive-deps
43
+ [client, subscriptionsProp]);
44
+ return (_jsx(KerbcastContext.Provider, { value: value, children: children }));
45
+ }
46
+ // ---------------------------------------------------------------------------
47
+ // Hooks
48
+ // ---------------------------------------------------------------------------
49
+ /**
50
+ * Return the `KerbcastClient` from the nearest `KerbcastProvider`.
51
+ * Throws if called outside a provider.
52
+ */
53
+ export function useKerbcastClient() {
54
+ const ctx = useContext(KerbcastContext);
55
+ if (!ctx) {
56
+ throw new Error("useKerbcastClient must be used inside a KerbcastProvider");
57
+ }
58
+ return ctx.client;
59
+ }
60
+ /**
61
+ * Return the `KerbcastSubscriptions` from the nearest `KerbcastProvider`.
62
+ * Throws if called outside a provider.
63
+ */
64
+ export function useKerbcastSubscriptions() {
65
+ const ctx = useContext(KerbcastContext);
66
+ if (!ctx) {
67
+ throw new Error("useKerbcastSubscriptions must be used inside a KerbcastProvider");
68
+ }
69
+ return ctx.subscriptions;
70
+ }
@@ -0,0 +1,8 @@
1
+ import type { CameraState } from "@jonpepler/kerbcam";
2
+ /**
3
+ * Live snapshot of the kerbcam camera registry. Returns the empty list
4
+ * before the data channel handshake completes (and after a disconnect).
5
+ * Subscribes via the underlying `KerbcamClient`'s `cameras-change` event for
6
+ * one synchronous push per server-side snapshot or state-changed message.
7
+ */
8
+ export declare function useKerbcamCameras(): CameraState[];
@@ -0,0 +1,19 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useKerbcamClient } from "../context";
3
+ /**
4
+ * Live snapshot of the kerbcam camera registry. Returns the empty list
5
+ * before the data channel handshake completes (and after a disconnect).
6
+ * Subscribes via the underlying `KerbcamClient`'s `cameras-change` event for
7
+ * one synchronous push per server-side snapshot or state-changed message.
8
+ */
9
+ export function useKerbcamCameras() {
10
+ const client = useKerbcamClient();
11
+ const [cameras, setCameras] = useState(() => [
12
+ ...client.cameras,
13
+ ]);
14
+ useEffect(() => {
15
+ setCameras([...client.cameras]);
16
+ return client.on("cameras-change", (next) => setCameras([...next]));
17
+ }, [client]);
18
+ return cameras;
19
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Live `MediaStream` for one kerbcam camera. Returns `null` while the WebRTC
3
+ * track hasn't arrived yet (during connection setup, or after a disconnect).
4
+ * Components bind the stream to a `<video>`'s `srcObject` directly.
5
+ *
6
+ * While mounted with a non-null `flightId`, acquires a subscription slot via
7
+ * the context `KerbcamSubscriptions`. Releases the slot when the component
8
+ * unmounts or the `flightId` changes.
9
+ */
10
+ export declare function useKerbcamStream(flightId: number | null): MediaStream | null;
@@ -0,0 +1,35 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useKerbcamClient, useKerbcamSubscriptions } from "../context";
3
+ /**
4
+ * Live `MediaStream` for one kerbcam camera. Returns `null` while the WebRTC
5
+ * track hasn't arrived yet (during connection setup, or after a disconnect).
6
+ * Components bind the stream to a `<video>`'s `srcObject` directly.
7
+ *
8
+ * While mounted with a non-null `flightId`, acquires a subscription slot via
9
+ * the context `KerbcamSubscriptions`. Releases the slot when the component
10
+ * unmounts or the `flightId` changes.
11
+ */
12
+ export function useKerbcamStream(flightId) {
13
+ const client = useKerbcamClient();
14
+ const subscriptions = useKerbcamSubscriptions();
15
+ const [stream, setStream] = useState(() => {
16
+ if (flightId === null)
17
+ return null;
18
+ return client.camera(flightId).mediaStream;
19
+ });
20
+ useEffect(() => {
21
+ if (flightId === null) {
22
+ setStream(null);
23
+ return;
24
+ }
25
+ const cam = client.camera(flightId);
26
+ setStream(cam.mediaStream);
27
+ const off = cam.on("stream", setStream);
28
+ subscriptions.acquire(flightId);
29
+ return () => {
30
+ off();
31
+ subscriptions.release(flightId);
32
+ };
33
+ }, [client, subscriptions, flightId]);
34
+ return stream;
35
+ }
@@ -0,0 +1,8 @@
1
+ import type { CameraState } from "@ksp-gonogo/kerbcast";
2
+ /**
3
+ * Live snapshot of the kerbcast camera registry. Returns the empty list
4
+ * before the data channel handshake completes (and after a disconnect).
5
+ * Subscribes via the underlying `KerbcastClient`'s `cameras-change` event for
6
+ * one synchronous push per server-side snapshot or state-changed message.
7
+ */
8
+ export declare function useKerbcastCameras(): CameraState[];
@@ -0,0 +1,19 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useKerbcastClient } from "../context";
3
+ /**
4
+ * Live snapshot of the kerbcast camera registry. Returns the empty list
5
+ * before the data channel handshake completes (and after a disconnect).
6
+ * Subscribes via the underlying `KerbcastClient`'s `cameras-change` event for
7
+ * one synchronous push per server-side snapshot or state-changed message.
8
+ */
9
+ export function useKerbcastCameras() {
10
+ const client = useKerbcastClient();
11
+ const [cameras, setCameras] = useState(() => [
12
+ ...client.cameras,
13
+ ]);
14
+ useEffect(() => {
15
+ setCameras([...client.cameras]);
16
+ return client.on("cameras-change", (next) => setCameras([...next]));
17
+ }, [client]);
18
+ return cameras;
19
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Mission-time capture clock, from the sidecar's ~1Hz `settings-state`.
3
+ *
4
+ * - `captureUt`: KSP universal time (seconds) the current video was captured
5
+ * at, or `null` when no clock is known (old plugin/sidecar, or before the
6
+ * first push). A consumer treats `null` as "no clock" and keeps a live
7
+ * passthrough.
8
+ * - `epoch`: bumps on a UT discontinuity (revert / quickload / scene reload);
9
+ * only the change is meaningful, so a consumer flushes and resyncs on it.
10
+ * - `warpRate`: current time-warp multiplier, for interpolating `captureUt`
11
+ * between samples; defaults to 1.
12
+ *
13
+ * This is a single global sim clock, not a per-camera value, so it takes no
14
+ * `flightId`.
15
+ */
16
+ export declare function useKerbcastClock(): {
17
+ captureUt: number | null;
18
+ epoch: number;
19
+ warpRate: number;
20
+ };
@@ -0,0 +1,26 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useKerbcastClient } from "../context";
3
+ /**
4
+ * Mission-time capture clock, from the sidecar's ~1Hz `settings-state`.
5
+ *
6
+ * - `captureUt`: KSP universal time (seconds) the current video was captured
7
+ * at, or `null` when no clock is known (old plugin/sidecar, or before the
8
+ * first push). A consumer treats `null` as "no clock" and keeps a live
9
+ * passthrough.
10
+ * - `epoch`: bumps on a UT discontinuity (revert / quickload / scene reload);
11
+ * only the change is meaningful, so a consumer flushes and resyncs on it.
12
+ * - `warpRate`: current time-warp multiplier, for interpolating `captureUt`
13
+ * between samples; defaults to 1.
14
+ *
15
+ * This is a single global sim clock, not a per-camera value, so it takes no
16
+ * `flightId`.
17
+ */
18
+ export function useKerbcastClock() {
19
+ const client = useKerbcastClient();
20
+ const [clock, setClock] = useState(() => client.clock);
21
+ useEffect(() => {
22
+ setClock(client.clock);
23
+ return client.on("settings-change", () => setClock(client.clock));
24
+ }, [client]);
25
+ return clock;
26
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Live `MediaStream` for one kerbcast camera. Returns `null` while the WebRTC
3
+ * track hasn't arrived yet (during connection setup, or after a disconnect).
4
+ * Components bind the stream to a `<video>`'s `srcObject` directly.
5
+ *
6
+ * While mounted with a non-null `flightId`, acquires a subscription slot via
7
+ * the context `KerbcastSubscriptions`. Releases the slot when the component
8
+ * unmounts or the `flightId` changes.
9
+ */
10
+ export declare function useKerbcastStream(flightId: number | null): MediaStream | null;
@@ -0,0 +1,35 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useKerbcastClient, useKerbcastSubscriptions } from "../context";
3
+ /**
4
+ * Live `MediaStream` for one kerbcast camera. Returns `null` while the WebRTC
5
+ * track hasn't arrived yet (during connection setup, or after a disconnect).
6
+ * Components bind the stream to a `<video>`'s `srcObject` directly.
7
+ *
8
+ * While mounted with a non-null `flightId`, acquires a subscription slot via
9
+ * the context `KerbcastSubscriptions`. Releases the slot when the component
10
+ * unmounts or the `flightId` changes.
11
+ */
12
+ export function useKerbcastStream(flightId) {
13
+ const client = useKerbcastClient();
14
+ const subscriptions = useKerbcastSubscriptions();
15
+ const [stream, setStream] = useState(() => {
16
+ if (flightId === null)
17
+ return null;
18
+ return client.camera(flightId).mediaStream;
19
+ });
20
+ useEffect(() => {
21
+ if (flightId === null) {
22
+ setStream(null);
23
+ return;
24
+ }
25
+ const cam = client.camera(flightId);
26
+ setStream(cam.mediaStream);
27
+ const off = cam.on("stream", setStream);
28
+ subscriptions.acquire(flightId);
29
+ return () => {
30
+ off();
31
+ subscriptions.release(flightId);
32
+ };
33
+ }, [client, subscriptions, flightId]);
34
+ return stream;
35
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @ksp-gonogo/kerbcast-react
3
+ *
4
+ * React components and hooks for kerbcast camera feeds. This package
5
+ * is a peer to @ksp-gonogo/kerbcast (the core SDK) and requires React 18+
6
+ * and styled-components 6+ as peer dependencies.
7
+ */
8
+ export { KerbcastProvider, createClientSubscriptions, useKerbcastClient, useKerbcastSubscriptions, } from "./context";
9
+ export type { KerbcastProviderProps, KerbcastSubscriptions } from "./context";
10
+ export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
11
+ export { useKerbcastStream } from "./hooks/useKerbcastStream";
12
+ export { useKerbcastClock } from "./hooks/useKerbcastClock";
13
+ export { buildCameraLabeler } from "./cameraLabels";
14
+ export type { LabelableCamera } from "./cameraLabels";
15
+ export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
16
+ export type { CameraLifecycle } from "./lifecycle";
17
+ export { CameraFeed } from "./CameraFeed";
18
+ export type { CameraFeedHandle, CameraFeedProps, CameraStreamHook, FeedAction, } from "./CameraFeed";
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @ksp-gonogo/kerbcast-react
3
+ *
4
+ * React components and hooks for kerbcast camera feeds. This package
5
+ * is a peer to @ksp-gonogo/kerbcast (the core SDK) and requires React 18+
6
+ * and styled-components 6+ as peer dependencies.
7
+ */
8
+ // Context and provider
9
+ export { KerbcastProvider, createClientSubscriptions, useKerbcastClient, useKerbcastSubscriptions, } from "./context";
10
+ // Hooks
11
+ export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
12
+ export { useKerbcastStream } from "./hooks/useKerbcastStream";
13
+ export { useKerbcastClock } from "./hooks/useKerbcastClock";
14
+ // Camera label utilities
15
+ export { buildCameraLabeler } from "./cameraLabels";
16
+ // Lifecycle utilities
17
+ export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
18
+ // CameraFeed component
19
+ export { CameraFeed } from "./CameraFeed";
@@ -0,0 +1,5 @@
1
+ import type { CameraState } from "@ksp-gonogo/kerbcast";
2
+ import { CameraLifecycle } from "@ksp-gonogo/kerbcast";
3
+ export type { CameraLifecycle };
4
+ export declare function getCameraLifecycle(cam: CameraState): CameraLifecycle;
5
+ export declare function isCameraDestroyed(cam: CameraState): boolean;
@@ -0,0 +1,9 @@
1
+ import { CameraLifecycle } from "@ksp-gonogo/kerbcast";
2
+ export function getCameraLifecycle(cam) {
3
+ return cam.lifecycle === CameraLifecycle.Destroyed
4
+ ? CameraLifecycle.Destroyed
5
+ : CameraLifecycle.Active;
6
+ }
7
+ export function isCameraDestroyed(cam) {
8
+ return getCameraLifecycle(cam) === CameraLifecycle.Destroyed;
9
+ }
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@ksp-gonogo/kerbcast-react",
3
+ "version": "1.4.0",
4
+ "description": "React components and hooks for kerbcast camera feeds.",
5
+ "license": "CC-BY-NC-SA-4.0",
6
+ "author": "Jon Pepler",
7
+ "homepage": "https://github.com/ksp-gonogo/kerbcast#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/ksp-gonogo/kerbcast/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/ksp-gonogo/kerbcast.git",
14
+ "directory": "client-sdk/react"
15
+ },
16
+ "keywords": [
17
+ "kerbcast",
18
+ "ksp",
19
+ "kerbal-space-program",
20
+ "webrtc",
21
+ "react",
22
+ "camera"
23
+ ],
24
+ "main": "dist/index.js",
25
+ "types": "dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist/",
34
+ "src/",
35
+ "README.md",
36
+ "CHANGELOG.md"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "dependencies": {
42
+ "@ksp-gonogo/kerbcast": "^1.4.0"
43
+ },
44
+ "peerDependencies": {
45
+ "react": "^18 || ^19",
46
+ "react-dom": "^18 || ^19",
47
+ "styled-components": "^6"
48
+ },
49
+ "devDependencies": {
50
+ "@testing-library/react": "^16.3.2",
51
+ "@testing-library/user-event": "^14.0.0",
52
+ "@types/react": "^18.3.28",
53
+ "@types/react-dom": "^18.3.0",
54
+ "@vitest/coverage-v8": "^4.1.4",
55
+ "jsdom": "^29.0.2",
56
+ "react": "^18.0.0",
57
+ "react-dom": "^18.0.0",
58
+ "styled-components": "^6.0.0",
59
+ "typescript": "^5.4.0",
60
+ "vitest": "^4.1.4"
61
+ },
62
+ "scripts": {
63
+ "build": "tsc",
64
+ "test": "vitest run",
65
+ "typecheck": "tsc --noEmit"
66
+ }
67
+ }