@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,82 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildCameraLabeler } from "./cameraLabels";
3
+ import type { LabelableCamera } from "./cameraLabels";
4
+
5
+ function cam(
6
+ flightId: number,
7
+ cameraName: string,
8
+ partTitle?: string | null,
9
+ ): LabelableCamera {
10
+ return { flightId, cameraName, partTitle };
11
+ }
12
+
13
+ describe("buildCameraLabeler", () => {
14
+ it("leaves non-colliding names unchanged", () => {
15
+ const cameras = [cam(1, "NavCam", "NavCam"), cam(2, "Tail Cam", "Some Part")];
16
+ const label = buildCameraLabeler(cameras);
17
+ expect(label(cameras[0]!)).toBe("NavCam");
18
+ expect(label(cameras[1]!)).toBe("Tail Cam");
19
+ });
20
+
21
+ it("disambiguates colliding names by part title when titles differ", () => {
22
+ const cameras = [
23
+ cam(1, "NavCam", "NavCam"),
24
+ cam(2, "NavCam", "Clamp-O-Tron Docking Port Jr."),
25
+ ];
26
+ const label = buildCameraLabeler(cameras);
27
+ expect(label(cameras[0]!)).toBe("NavCam");
28
+ expect(label(cameras[1]!)).toBe("NavCam - Clamp-O-Tron Docking Port Jr.");
29
+ });
30
+
31
+ it("numbers identical cameras that the part title cannot tell apart", () => {
32
+ const cameras = [
33
+ cam(10, "KazzelBlad - BoosterCam", "KazzelBlad - BoosterCam"),
34
+ cam(20, "KazzelBlad - BoosterCam", "KazzelBlad - BoosterCam"),
35
+ cam(30, "KazzelBlad - BoosterCam", "KazzelBlad - BoosterCam"),
36
+ ];
37
+ const label = buildCameraLabeler(cameras);
38
+ expect(label(cameras[0]!)).toBe("KazzelBlad - BoosterCam #1");
39
+ expect(label(cameras[1]!)).toBe("KazzelBlad - BoosterCam #2");
40
+ expect(label(cameras[2]!)).toBe("KazzelBlad - BoosterCam #3");
41
+ });
42
+
43
+ it("numbers by flightId order, not list order", () => {
44
+ const cameras = [
45
+ cam(30, "BoosterCam", "BoosterCam"),
46
+ cam(10, "BoosterCam", "BoosterCam"),
47
+ cam(20, "BoosterCam", "BoosterCam"),
48
+ ];
49
+ const label = buildCameraLabeler(cameras);
50
+ expect(label(cameras[0]!)).toBe("BoosterCam #3");
51
+ expect(label(cameras[1]!)).toBe("BoosterCam #1");
52
+ expect(label(cameras[2]!)).toBe("BoosterCam #2");
53
+ });
54
+
55
+ it("keeps numbers stable when unrelated cameras join the list", () => {
56
+ const a = cam(10, "BoosterCam", "BoosterCam");
57
+ const b = cam(20, "BoosterCam", "BoosterCam");
58
+ const before = buildCameraLabeler([a, b]);
59
+ const after = buildCameraLabeler([a, b, cam(5, "NavCam", "NavCam")]);
60
+ expect(after(a)).toBe(before(a));
61
+ expect(after(b)).toBe(before(b));
62
+ });
63
+
64
+ it("numbers cameras whose part titles collide too, after title disambiguation", () => {
65
+ const cameras = [
66
+ cam(1, "NavCam", "NavCam"),
67
+ cam(2, "NavCam", "Clamp-O-Tron"),
68
+ cam(3, "NavCam", "Clamp-O-Tron"),
69
+ ];
70
+ const label = buildCameraLabeler(cameras);
71
+ expect(label(cameras[0]!)).toBe("NavCam");
72
+ expect(label(cameras[1]!)).toBe("NavCam - Clamp-O-Tron #1");
73
+ expect(label(cameras[2]!)).toBe("NavCam - Clamp-O-Tron #2");
74
+ });
75
+
76
+ it("falls back to numbering when colliding cameras have no part title", () => {
77
+ const cameras = [cam(1, "Camera", null), cam(2, "Camera", undefined)];
78
+ const label = buildCameraLabeler(cameras);
79
+ expect(label(cameras[0]!)).toBe("Camera #1");
80
+ expect(label(cameras[1]!)).toBe("Camera #2");
81
+ });
82
+ });
@@ -0,0 +1,68 @@
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
+ export interface LabelableCamera {
19
+ flightId: number;
20
+ cameraName: string;
21
+ partTitle?: string | null;
22
+ }
23
+
24
+ /**
25
+ * Build a labeller closed over the current camera list. The returned function
26
+ * maps a camera to its display name, disambiguating only the cameras whose
27
+ * `cameraName` collides with another in the same list.
28
+ *
29
+ * The label does NOT include the vessel name. Call sites append that (and any
30
+ * " - signal lost" suffix) themselves.
31
+ */
32
+ export function buildCameraLabeler<T extends LabelableCamera>(
33
+ cameras: readonly T[],
34
+ ): (camera: T) => string {
35
+ const counts = new Map<string, number>();
36
+ for (const c of cameras) {
37
+ counts.set(c.cameraName, (counts.get(c.cameraName) ?? 0) + 1);
38
+ }
39
+ const baseLabel = (camera: T): string =>
40
+ (counts.get(camera.cameraName) ?? 0) > 1 &&
41
+ camera.partTitle &&
42
+ camera.partTitle !== camera.cameraName
43
+ ? `${camera.cameraName} - ${camera.partTitle}`
44
+ : camera.cameraName;
45
+
46
+ /* Number the cameras whose base label still collides: per colliding label,
47
+ sort by flightId and hand out #1..#N. */
48
+ const byLabel = new Map<string, T[]>();
49
+ for (const c of cameras) {
50
+ const label = baseLabel(c);
51
+ const group = byLabel.get(label);
52
+ if (group) group.push(c);
53
+ else byLabel.set(label, [c]);
54
+ }
55
+ const ordinals = new Map<number, number>();
56
+ for (const group of byLabel.values()) {
57
+ if (group.length < 2) continue;
58
+ [...group]
59
+ .sort((a, b) => a.flightId - b.flightId)
60
+ .forEach((c, i) => ordinals.set(c.flightId, i + 1));
61
+ }
62
+
63
+ return (camera: T): string => {
64
+ const label = baseLabel(camera);
65
+ const ordinal = ordinals.get(camera.flightId);
66
+ return ordinal === undefined ? label : `${label} #${ordinal}`;
67
+ };
68
+ }
@@ -0,0 +1,129 @@
1
+ import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
2
+ import {
3
+ createContext,
4
+ useContext,
5
+ useMemo,
6
+ useRef,
7
+ type ReactNode,
8
+ } from "react";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Subscriptions seam
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * Lifecycle controller for per-camera slot subscriptions. The default
16
+ * implementation refcounts so multiple widgets sharing the same flightId
17
+ * share one slot.
18
+ */
19
+ export interface KerbcastSubscriptions {
20
+ acquire(flightId: number): void;
21
+ release(flightId: number): void;
22
+ }
23
+
24
+ /** Build the default refcounted subscriptions implementation for a client. */
25
+ export function createClientSubscriptions(
26
+ client: KerbcastClient,
27
+ ): KerbcastSubscriptions {
28
+ const refcounts = new Map<number, number>();
29
+ return {
30
+ acquire(flightId: number): void {
31
+ const count = (refcounts.get(flightId) ?? 0) + 1;
32
+ refcounts.set(flightId, count);
33
+ if (count === 1) {
34
+ client.subscribe(flightId).catch(() => {});
35
+ }
36
+ },
37
+ release(flightId: number): void {
38
+ const count = (refcounts.get(flightId) ?? 1) - 1;
39
+ if (count <= 0) {
40
+ refcounts.delete(flightId);
41
+ client.unsubscribe(flightId).catch(() => {});
42
+ } else {
43
+ refcounts.set(flightId, count);
44
+ }
45
+ },
46
+ };
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Context shape
51
+ // ---------------------------------------------------------------------------
52
+
53
+ interface KerbcastContextValue {
54
+ client: KerbcastClient;
55
+ subscriptions: KerbcastSubscriptions;
56
+ }
57
+
58
+ const KerbcastContext = createContext<KerbcastContextValue | null>(null);
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Provider
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export interface KerbcastProviderProps {
65
+ client: KerbcastClient;
66
+ /** Override the subscription manager. Defaults to a refcounted per-client impl. */
67
+ subscriptions?: KerbcastSubscriptions;
68
+ children: ReactNode;
69
+ }
70
+
71
+ /**
72
+ * Provide a `KerbcastClient` (and optional subscriptions override) to the
73
+ * component subtree. Hooks and `CameraFeed` read both from this context.
74
+ */
75
+ export function KerbcastProvider({
76
+ client,
77
+ subscriptions: subscriptionsProp,
78
+ children,
79
+ }: KerbcastProviderProps): React.JSX.Element {
80
+ // Create default subscriptions once per client. A ref so the object is
81
+ // stable across renders even if the parent re-renders.
82
+ const defaultSubsRef = useRef<KerbcastSubscriptions | null>(null);
83
+ if (defaultSubsRef.current === null) {
84
+ defaultSubsRef.current = createClientSubscriptions(client);
85
+ }
86
+
87
+ const value = useMemo(
88
+ () => ({
89
+ client,
90
+ subscriptions: subscriptionsProp ?? defaultSubsRef.current!,
91
+ }),
92
+ // eslint-disable-next-line react-hooks/exhaustive-deps
93
+ [client, subscriptionsProp],
94
+ );
95
+
96
+ return (
97
+ <KerbcastContext.Provider value={value}>{children}</KerbcastContext.Provider>
98
+ );
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Hooks
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Return the `KerbcastClient` from the nearest `KerbcastProvider`.
107
+ * Throws if called outside a provider.
108
+ */
109
+ export function useKerbcastClient(): KerbcastClient {
110
+ const ctx = useContext(KerbcastContext);
111
+ if (!ctx) {
112
+ throw new Error("useKerbcastClient must be used inside a KerbcastProvider");
113
+ }
114
+ return ctx.client;
115
+ }
116
+
117
+ /**
118
+ * Return the `KerbcastSubscriptions` from the nearest `KerbcastProvider`.
119
+ * Throws if called outside a provider.
120
+ */
121
+ export function useKerbcastSubscriptions(): KerbcastSubscriptions {
122
+ const ctx = useContext(KerbcastContext);
123
+ if (!ctx) {
124
+ throw new Error(
125
+ "useKerbcastSubscriptions must be used inside a KerbcastProvider",
126
+ );
127
+ }
128
+ return ctx.subscriptions;
129
+ }
@@ -0,0 +1,24 @@
1
+ import type { CameraState } from "@ksp-gonogo/kerbcast";
2
+ import { useEffect, useState } from "react";
3
+ import { useKerbcastClient } from "../context";
4
+
5
+ /**
6
+ * Live snapshot of the kerbcast camera registry. Returns the empty list
7
+ * before the data channel handshake completes (and after a disconnect).
8
+ * Subscribes via the underlying `KerbcastClient`'s `cameras-change` event for
9
+ * one synchronous push per server-side snapshot or state-changed message.
10
+ */
11
+ export function useKerbcastCameras(): CameraState[] {
12
+ const client = useKerbcastClient();
13
+
14
+ const [cameras, setCameras] = useState<CameraState[]>(() => [
15
+ ...client.cameras,
16
+ ]);
17
+
18
+ useEffect(() => {
19
+ setCameras([...client.cameras]);
20
+ return client.on("cameras-change", (next) => setCameras([...next]));
21
+ }, [client]);
22
+
23
+ return cameras;
24
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * useKerbcastClock capture-clock subscription.
3
+ *
4
+ * Renders a probe through the real hook + real KerbcastClient with only the
5
+ * WebRTC transport faked by the SDK's MockSidecar. Asserts the hook seeds from
6
+ * the client and re-renders on each `settings-state` push.
7
+ */
8
+
9
+ import { KerbcastClient } from "@ksp-gonogo/kerbcast";
10
+ import { MockSidecar } from "@ksp-gonogo/kerbcast/testing";
11
+ import { act, cleanup, render } from "@testing-library/react";
12
+ import { afterEach, describe, expect, it } from "vitest";
13
+ import { KerbcastProvider } from "../context";
14
+ import { useKerbcastClock } from "./useKerbcastClock";
15
+
16
+ let lastClock: ReturnType<typeof useKerbcastClock> | undefined;
17
+
18
+ function ClockProbe(): null {
19
+ lastClock = useKerbcastClock();
20
+ return null;
21
+ }
22
+
23
+ async function connected(): Promise<{
24
+ client: KerbcastClient;
25
+ sidecar: MockSidecar;
26
+ }> {
27
+ const sidecar = new MockSidecar();
28
+ const client = new KerbcastClient(
29
+ { host: "h", port: 1, negotiate: (o) => sidecar.negotiate(o) },
30
+ sidecar.createTransport(),
31
+ );
32
+ await act(async () => {
33
+ await client.connect([], { slots: 4 });
34
+ sidecar.open();
35
+ sidecar.setConnectionState("connected");
36
+ });
37
+ return { client, sidecar };
38
+ }
39
+
40
+ afterEach(() => {
41
+ cleanup();
42
+ lastClock = undefined;
43
+ });
44
+
45
+ describe("useKerbcastClock", () => {
46
+ it("starts with no clock and updates on a settings-state push", async () => {
47
+ const { client, sidecar } = await connected();
48
+
49
+ render(
50
+ <KerbcastProvider client={client}>
51
+ <ClockProbe />
52
+ </KerbcastProvider>,
53
+ );
54
+
55
+ /* Open sent a bare settings-state (no capture fields) => no clock. */
56
+ expect(lastClock).toEqual({ captureUt: null, epoch: 0, warpRate: 1 });
57
+
58
+ act(() => {
59
+ sidecar.fireSettingsState({
60
+ throttleMainScreen: false,
61
+ captureUt: 500.25,
62
+ captureEpoch: 2,
63
+ timeWarpRate: 10,
64
+ });
65
+ });
66
+
67
+ expect(lastClock).toEqual({ captureUt: 500.25, epoch: 2, warpRate: 10 });
68
+
69
+ await act(async () => {
70
+ await client.disconnect();
71
+ });
72
+ });
73
+ });
@@ -0,0 +1,33 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useKerbcastClient } from "../context";
3
+
4
+ /**
5
+ * Mission-time capture clock, from the sidecar's ~1Hz `settings-state`.
6
+ *
7
+ * - `captureUt`: KSP universal time (seconds) the current video was captured
8
+ * at, or `null` when no clock is known (old plugin/sidecar, or before the
9
+ * first push). A consumer treats `null` as "no clock" and keeps a live
10
+ * passthrough.
11
+ * - `epoch`: bumps on a UT discontinuity (revert / quickload / scene reload);
12
+ * only the change is meaningful, so a consumer flushes and resyncs on it.
13
+ * - `warpRate`: current time-warp multiplier, for interpolating `captureUt`
14
+ * between samples; defaults to 1.
15
+ *
16
+ * This is a single global sim clock, not a per-camera value, so it takes no
17
+ * `flightId`.
18
+ */
19
+ export function useKerbcastClock(): {
20
+ captureUt: number | null;
21
+ epoch: number;
22
+ warpRate: number;
23
+ } {
24
+ const client = useKerbcastClient();
25
+ const [clock, setClock] = useState(() => client.clock);
26
+
27
+ useEffect(() => {
28
+ setClock(client.clock);
29
+ return client.on("settings-change", () => setClock(client.clock));
30
+ }, [client]);
31
+
32
+ return clock;
33
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * useKerbcastStream slot-subscription lifecycle.
3
+ *
4
+ * Renders a probe component through the real hook + real KerbcastClient,
5
+ * with only the WebRTC transport faked by the SDK's canonical MockSidecar.
6
+ * Asserts the hook drives the dynamic-mode subscription: a slot binds while a
7
+ * camera is on screen, switches when the selected flightId changes, and frees
8
+ * on unmount.
9
+ */
10
+
11
+ import { KerbcastClient } from "@ksp-gonogo/kerbcast";
12
+ import { MockSidecar } from "@ksp-gonogo/kerbcast/testing";
13
+ import { act, cleanup, render } from "@testing-library/react";
14
+ import { afterEach, describe, expect, it } from "vitest";
15
+ import { KerbcastProvider } from "../context";
16
+ import { useKerbcastStream } from "./useKerbcastStream";
17
+
18
+ function StreamProbe({ flightId }: { flightId: number | null }): null {
19
+ useKerbcastStream(flightId);
20
+ return null;
21
+ }
22
+
23
+ function Wrapper({
24
+ client,
25
+ flightId,
26
+ }: {
27
+ client: KerbcastClient;
28
+ flightId: number | null;
29
+ }): React.JSX.Element {
30
+ return (
31
+ <KerbcastProvider client={client}>
32
+ <StreamProbe flightId={flightId} />
33
+ </KerbcastProvider>
34
+ );
35
+ }
36
+
37
+ async function connectedSource(
38
+ flightIds: number[] = [42, 43],
39
+ ): Promise<{ client: KerbcastClient; sidecar: MockSidecar }> {
40
+ const sidecar = new MockSidecar();
41
+ for (const flightId of flightIds) {
42
+ sidecar.addCamera({ flightId });
43
+ }
44
+ const client = new KerbcastClient(
45
+ { host: "h", port: 1, negotiate: (o) => sidecar.negotiate(o) },
46
+ sidecar.createTransport(),
47
+ );
48
+ await act(async () => {
49
+ await client.connect([], { slots: 4 });
50
+ sidecar.open();
51
+ sidecar.setConnectionState("connected");
52
+ });
53
+ return { client, sidecar };
54
+ }
55
+
56
+ afterEach(() => {
57
+ cleanup();
58
+ });
59
+
60
+ describe("useKerbcastStream - slot subscription lifecycle", () => {
61
+ it("subscribes the camera on mount and releases it on unmount", async () => {
62
+ const { client, sidecar } = await connectedSource();
63
+
64
+ const { unmount } = render(<Wrapper client={client} flightId={42} />);
65
+
66
+ expect(sidecar.lastCommand("subscribe", 42)).toBeTruthy();
67
+ expect(sidecar.slotMidFor(42)).toBeDefined();
68
+
69
+ act(() => {
70
+ unmount();
71
+ });
72
+
73
+ // Disconnect after unmount so stream-null emission hits no mounted widget.
74
+ await act(async () => {
75
+ await client.disconnect();
76
+ });
77
+
78
+ expect(sidecar.lastCommand("unsubscribe", 42)).toBeTruthy();
79
+ expect(sidecar.slotMidFor(42)).toBeUndefined();
80
+ });
81
+
82
+ it("switches slots when the selected flightId changes", async () => {
83
+ const { client, sidecar } = await connectedSource();
84
+
85
+ const { rerender, unmount } = render(
86
+ <Wrapper client={client} flightId={42} />,
87
+ );
88
+ expect(sidecar.slotMidFor(42)).toBeDefined();
89
+
90
+ act(() => {
91
+ rerender(<Wrapper client={client} flightId={43} />);
92
+ });
93
+
94
+ expect(sidecar.slotMidFor(42)).toBeUndefined();
95
+ expect(sidecar.slotMidFor(43)).toBeDefined();
96
+
97
+ act(() => {
98
+ unmount();
99
+ });
100
+ await act(async () => {
101
+ await client.disconnect();
102
+ });
103
+ });
104
+
105
+ it("does not subscribe when no camera is selected", async () => {
106
+ const { client, sidecar } = await connectedSource();
107
+
108
+ const { unmount } = render(<Wrapper client={client} flightId={null} />);
109
+
110
+ expect(sidecar.commands.some((c) => c.type === "subscribe")).toBe(false);
111
+
112
+ act(() => {
113
+ unmount();
114
+ });
115
+ await act(async () => {
116
+ await client.disconnect();
117
+ });
118
+ });
119
+ });
@@ -0,0 +1,38 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useKerbcastClient, useKerbcastSubscriptions } from "../context";
3
+
4
+ /**
5
+ * Live `MediaStream` for one kerbcast camera. Returns `null` while the WebRTC
6
+ * track hasn't arrived yet (during connection setup, or after a disconnect).
7
+ * Components bind the stream to a `<video>`'s `srcObject` directly.
8
+ *
9
+ * While mounted with a non-null `flightId`, acquires a subscription slot via
10
+ * the context `KerbcastSubscriptions`. Releases the slot when the component
11
+ * unmounts or the `flightId` changes.
12
+ */
13
+ export function useKerbcastStream(flightId: number | null): MediaStream | null {
14
+ const client = useKerbcastClient();
15
+ const subscriptions = useKerbcastSubscriptions();
16
+
17
+ const [stream, setStream] = useState<MediaStream | null>(() => {
18
+ if (flightId === null) return null;
19
+ return client.camera(flightId).mediaStream;
20
+ });
21
+
22
+ useEffect(() => {
23
+ if (flightId === null) {
24
+ setStream(null);
25
+ return;
26
+ }
27
+ const cam = client.camera(flightId);
28
+ setStream(cam.mediaStream);
29
+ const off = cam.on("stream", setStream);
30
+ subscriptions.acquire(flightId);
31
+ return () => {
32
+ off();
33
+ subscriptions.release(flightId);
34
+ };
35
+ }, [client, subscriptions, flightId]);
36
+
37
+ return stream;
38
+ }
@@ -0,0 +1,10 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ // Smoke test: the package module resolves and its shape is what consumers
4
+ // expect. Real component tests land with the CameraFeed extraction.
5
+ describe("@ksp-gonogo/kerbcast-react", () => {
6
+ it("module resolves", async () => {
7
+ const mod = await import("./index");
8
+ expect(mod).toBeDefined();
9
+ });
10
+ });
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
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
+
9
+ // Context and provider
10
+ export {
11
+ KerbcastProvider,
12
+ createClientSubscriptions,
13
+ useKerbcastClient,
14
+ useKerbcastSubscriptions,
15
+ } from "./context";
16
+ export type { KerbcastProviderProps, KerbcastSubscriptions } from "./context";
17
+
18
+ // Hooks
19
+ export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
20
+ export { useKerbcastStream } from "./hooks/useKerbcastStream";
21
+ export { useKerbcastClock } from "./hooks/useKerbcastClock";
22
+
23
+ // Camera label utilities
24
+ export { buildCameraLabeler } from "./cameraLabels";
25
+ export type { LabelableCamera } from "./cameraLabels";
26
+
27
+ // Lifecycle utilities
28
+ export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
29
+ export type { CameraLifecycle } from "./lifecycle";
30
+
31
+ // CameraFeed component
32
+ export { CameraFeed } from "./CameraFeed";
33
+ export type {
34
+ CameraFeedHandle,
35
+ CameraFeedProps,
36
+ CameraStreamHook,
37
+ FeedAction,
38
+ } from "./CameraFeed";
@@ -0,0 +1,14 @@
1
+ import type { CameraState } from "@ksp-gonogo/kerbcast";
2
+ import { CameraLifecycle } from "@ksp-gonogo/kerbcast";
3
+
4
+ export type { CameraLifecycle };
5
+
6
+ export function getCameraLifecycle(cam: CameraState): CameraLifecycle {
7
+ return cam.lifecycle === CameraLifecycle.Destroyed
8
+ ? CameraLifecycle.Destroyed
9
+ : CameraLifecycle.Active;
10
+ }
11
+
12
+ export function isCameraDestroyed(cam: CameraState): boolean {
13
+ return getCameraLifecycle(cam) === CameraLifecycle.Destroyed;
14
+ }
@@ -0,0 +1,3 @@
1
+ import { installDomStubs } from "@ksp-gonogo/kerbcast/testing";
2
+
3
+ installDomStubs();