@ksp-gonogo/kerbcast-react 1.6.2 → 1.7.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.
- package/CHANGELOG.md +41 -0
- package/README.md +19 -0
- package/dist/CameraFeed.d.ts +31 -3
- package/dist/CameraFeed.js +86 -49
- package/dist/KerbalFaceFeed.d.ts +82 -0
- package/dist/KerbalFaceFeed.js +118 -0
- package/dist/context.d.ts +27 -1
- package/dist/context.js +68 -2
- package/dist/hooks/useReportDisplaySize.d.ts +23 -0
- package/dist/hooks/useReportDisplaySize.js +71 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +176 -12
- package/src/CameraFeed.tsx +196 -56
- package/src/KerbalFaceFeed.test.tsx +203 -0
- package/src/KerbalFaceFeed.tsx +236 -0
- package/src/context.test.tsx +94 -0
- package/src/context.tsx +98 -1
- package/src/hooks/useReportDisplaySize.test.tsx +166 -0
- package/src/hooks/useReportDisplaySize.ts +91 -0
- package/src/index.ts +12 -1
package/dist/context.d.ts
CHANGED
|
@@ -11,17 +11,38 @@ export interface KerbcastSubscriptions {
|
|
|
11
11
|
}
|
|
12
12
|
/** Build the default refcounted subscriptions implementation for a client. */
|
|
13
13
|
export declare function createClientSubscriptions(client: KerbcastClient): KerbcastSubscriptions;
|
|
14
|
+
/**
|
|
15
|
+
* Collects each mounted feed's self-measured display size and drives the
|
|
16
|
+
* client's per-consumer `reportDisplaySize`. Multiple feeds can show the SAME
|
|
17
|
+
* camera at once (e.g. a tiny avatar and a spotlight of the same kerbal); the
|
|
18
|
+
* sidecar keys the auto-resolution max per CONSUMER, so those would clobber
|
|
19
|
+
* each other last-writer-wins. This collapses them at the client edge: it
|
|
20
|
+
* keeps the MAX display size across every mounted reporter for a flightId and
|
|
21
|
+
* sends one report per max-change. Co-located with the subscription refcount
|
|
22
|
+
* (same per-flightId lifetime) so one object owns everything this client is
|
|
23
|
+
* doing with camera N.
|
|
24
|
+
*/
|
|
25
|
+
export interface KerbcastDisplaySizes {
|
|
26
|
+
/** Register/update a reporter's measured size for a camera. */
|
|
27
|
+
report(flightId: number, instanceId: string, width: number, height: number): void;
|
|
28
|
+
/** Deregister a reporter (on unmount / flightId change). */
|
|
29
|
+
clear(flightId: number, instanceId: string): void;
|
|
30
|
+
}
|
|
31
|
+
/** Build the default per-flightId MAX display-size registry for a client. */
|
|
32
|
+
export declare function createClientDisplaySizes(client: KerbcastClient): KerbcastDisplaySizes;
|
|
14
33
|
export interface KerbcastProviderProps {
|
|
15
34
|
client: KerbcastClient;
|
|
16
35
|
/** Override the subscription manager. Defaults to a refcounted per-client impl. */
|
|
17
36
|
subscriptions?: KerbcastSubscriptions;
|
|
37
|
+
/** Override the display-size registry. Defaults to a per-flightId MAX impl. */
|
|
38
|
+
displaySizes?: KerbcastDisplaySizes;
|
|
18
39
|
children: ReactNode;
|
|
19
40
|
}
|
|
20
41
|
/**
|
|
21
42
|
* Provide a `KerbcastClient` (and optional subscriptions override) to the
|
|
22
43
|
* component subtree. Hooks and `CameraFeed` read both from this context.
|
|
23
44
|
*/
|
|
24
|
-
export declare function KerbcastProvider({ client, subscriptions: subscriptionsProp, children, }: KerbcastProviderProps): React.JSX.Element;
|
|
45
|
+
export declare function KerbcastProvider({ client, subscriptions: subscriptionsProp, displaySizes: displaySizesProp, children, }: KerbcastProviderProps): React.JSX.Element;
|
|
25
46
|
/**
|
|
26
47
|
* Return the `KerbcastClient` from the nearest `KerbcastProvider`.
|
|
27
48
|
* Throws if called outside a provider.
|
|
@@ -32,3 +53,8 @@ export declare function useKerbcastClient(): KerbcastClient;
|
|
|
32
53
|
* Throws if called outside a provider.
|
|
33
54
|
*/
|
|
34
55
|
export declare function useKerbcastSubscriptions(): KerbcastSubscriptions;
|
|
56
|
+
/**
|
|
57
|
+
* Return the `KerbcastDisplaySizes` registry from the nearest
|
|
58
|
+
* `KerbcastProvider`. Throws if called outside a provider.
|
|
59
|
+
*/
|
|
60
|
+
export declare function useKerbcastDisplaySizes(): KerbcastDisplaySizes;
|
package/dist/context.js
CHANGED
|
@@ -23,24 +23,79 @@ export function createClientSubscriptions(client) {
|
|
|
23
23
|
},
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
+
/** Build the default per-flightId MAX display-size registry for a client. */
|
|
27
|
+
export function createClientDisplaySizes(client) {
|
|
28
|
+
// flightId -> (instanceId -> measured size)
|
|
29
|
+
const perCamera = new Map();
|
|
30
|
+
// Last size reported to the sidecar per flightId, so we send only on change.
|
|
31
|
+
const lastSent = new Map();
|
|
32
|
+
function recompute(flightId) {
|
|
33
|
+
const instances = perCamera.get(flightId);
|
|
34
|
+
if (!instances || instances.size === 0) {
|
|
35
|
+
// No reporters left. Drop bookkeeping and send nothing: the subscription
|
|
36
|
+
// release (unsubscribe -> sidecar remove_track -> forget_display_size)
|
|
37
|
+
// clears the sidecar side. Sending a spurious size here would fight it.
|
|
38
|
+
perCamera.delete(flightId);
|
|
39
|
+
lastSent.delete(flightId);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
let maxW = 0;
|
|
43
|
+
let maxH = 0;
|
|
44
|
+
for (const { w, h } of instances.values()) {
|
|
45
|
+
if (w > maxW)
|
|
46
|
+
maxW = w;
|
|
47
|
+
if (h > maxH)
|
|
48
|
+
maxH = h;
|
|
49
|
+
}
|
|
50
|
+
const prev = lastSent.get(flightId);
|
|
51
|
+
if (prev && prev.w === maxW && prev.h === maxH)
|
|
52
|
+
return;
|
|
53
|
+
lastSent.set(flightId, { w: maxW, h: maxH });
|
|
54
|
+
client.reportDisplaySize(flightId, maxW, maxH).catch(() => { });
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
report(flightId, instanceId, width, height) {
|
|
58
|
+
let instances = perCamera.get(flightId);
|
|
59
|
+
if (!instances) {
|
|
60
|
+
instances = new Map();
|
|
61
|
+
perCamera.set(flightId, instances);
|
|
62
|
+
}
|
|
63
|
+
instances.set(instanceId, { w: width, h: height });
|
|
64
|
+
recompute(flightId);
|
|
65
|
+
},
|
|
66
|
+
clear(flightId, instanceId) {
|
|
67
|
+
const instances = perCamera.get(flightId);
|
|
68
|
+
if (!instances)
|
|
69
|
+
return;
|
|
70
|
+
instances.delete(instanceId);
|
|
71
|
+
recompute(flightId);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
26
75
|
const KerbcastContext = createContext(null);
|
|
27
76
|
/**
|
|
28
77
|
* Provide a `KerbcastClient` (and optional subscriptions override) to the
|
|
29
78
|
* component subtree. Hooks and `CameraFeed` read both from this context.
|
|
30
79
|
*/
|
|
31
|
-
export function KerbcastProvider({ client, subscriptions: subscriptionsProp, children, }) {
|
|
80
|
+
export function KerbcastProvider({ client, subscriptions: subscriptionsProp, displaySizes: displaySizesProp, children, }) {
|
|
32
81
|
// Create default subscriptions once per client. A ref so the object is
|
|
33
82
|
// stable across renders even if the parent re-renders.
|
|
34
83
|
const defaultSubsRef = useRef(null);
|
|
35
84
|
if (defaultSubsRef.current === null) {
|
|
36
85
|
defaultSubsRef.current = createClientSubscriptions(client);
|
|
37
86
|
}
|
|
87
|
+
// Same for the display-size registry: one per client, stable across renders.
|
|
88
|
+
const defaultSizesRef = useRef(null);
|
|
89
|
+
if (defaultSizesRef.current === null) {
|
|
90
|
+
defaultSizesRef.current = createClientDisplaySizes(client);
|
|
91
|
+
}
|
|
38
92
|
const value = useMemo(() => ({
|
|
39
93
|
client,
|
|
40
94
|
subscriptions: subscriptionsProp ?? defaultSubsRef.current,
|
|
95
|
+
displaySizes: displaySizesProp ?? defaultSizesRef.current,
|
|
41
96
|
}),
|
|
42
97
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
43
|
-
[client, subscriptionsProp]);
|
|
98
|
+
[client, subscriptionsProp, displaySizesProp]);
|
|
44
99
|
return (_jsx(KerbcastContext.Provider, { value: value, children: children }));
|
|
45
100
|
}
|
|
46
101
|
// ---------------------------------------------------------------------------
|
|
@@ -68,3 +123,14 @@ export function useKerbcastSubscriptions() {
|
|
|
68
123
|
}
|
|
69
124
|
return ctx.subscriptions;
|
|
70
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Return the `KerbcastDisplaySizes` registry from the nearest
|
|
128
|
+
* `KerbcastProvider`. Throws if called outside a provider.
|
|
129
|
+
*/
|
|
130
|
+
export function useKerbcastDisplaySizes() {
|
|
131
|
+
const ctx = useContext(KerbcastContext);
|
|
132
|
+
if (!ctx) {
|
|
133
|
+
throw new Error("useKerbcastDisplaySizes must be used inside a KerbcastProvider");
|
|
134
|
+
}
|
|
135
|
+
return ctx.displaySizes;
|
|
136
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface ReportDisplaySizeOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Report while mounted. Default true. Set false for a fixed-resolution feed
|
|
4
|
+
* that should not drive auto-resolution. This is a RESOLUTION concern only,
|
|
5
|
+
* independent of whether the feed shows its action UI.
|
|
6
|
+
*/
|
|
7
|
+
enabled?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Report a square (side x side, the min edge) rather than the raw w x h.
|
|
10
|
+
* Face cams are square; part cams are not.
|
|
11
|
+
*/
|
|
12
|
+
square?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Self-measure a feed element and drive the client's per-consumer
|
|
16
|
+
* `reportDisplaySize` (via the context display-size registry, which collapses
|
|
17
|
+
* multiple feeds of the same camera to one MAX report). Attach `ref` to the
|
|
18
|
+
* element whose rendered pixel box is the display size. Reporting follows the
|
|
19
|
+
* mount lifecycle: on unmount / `flightId` change / `enabled` flip it
|
|
20
|
+
* deregisters, and the sidecar's clear-on-departure (unsubscribe) does the
|
|
21
|
+
* server-side clear.
|
|
22
|
+
*/
|
|
23
|
+
export declare function useReportDisplaySize(flightId: number | null, ref: React.RefObject<Element | null>, { enabled, square }?: ReportDisplaySizeOptions): void;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useId } from "react";
|
|
2
|
+
import { useKerbcastDisplaySizes } from "../context";
|
|
3
|
+
/*
|
|
4
|
+
* Debounce + bucket for self-measured display-size reporting. The first
|
|
5
|
+
* measurement after mount reports promptly (so a feed never sits at a stale
|
|
6
|
+
* resolution until it is resized); later churn is debounced. Sizes are ceiled
|
|
7
|
+
* to a BUCKET_PX multiple: that meets-the-minimum-need (never under-serve),
|
|
8
|
+
* yields an even value (H.264-friendly), and gives a material-change threshold
|
|
9
|
+
* so a feed re-reports only when a resize crosses a bucket boundary.
|
|
10
|
+
*/
|
|
11
|
+
const DEBOUNCE_MS = 200;
|
|
12
|
+
const BUCKET_PX = 16;
|
|
13
|
+
function bucket(px) {
|
|
14
|
+
const b = Math.ceil(px / BUCKET_PX) * BUCKET_PX;
|
|
15
|
+
return b < BUCKET_PX ? BUCKET_PX : b;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Self-measure a feed element and drive the client's per-consumer
|
|
19
|
+
* `reportDisplaySize` (via the context display-size registry, which collapses
|
|
20
|
+
* multiple feeds of the same camera to one MAX report). Attach `ref` to the
|
|
21
|
+
* element whose rendered pixel box is the display size. Reporting follows the
|
|
22
|
+
* mount lifecycle: on unmount / `flightId` change / `enabled` flip it
|
|
23
|
+
* deregisters, and the sidecar's clear-on-departure (unsubscribe) does the
|
|
24
|
+
* server-side clear.
|
|
25
|
+
*/
|
|
26
|
+
export function useReportDisplaySize(flightId, ref, { enabled = true, square = false } = {}) {
|
|
27
|
+
const displaySizes = useKerbcastDisplaySizes();
|
|
28
|
+
const instanceId = useId();
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (flightId === null || !enabled)
|
|
31
|
+
return;
|
|
32
|
+
const el = ref.current;
|
|
33
|
+
if (!el)
|
|
34
|
+
return;
|
|
35
|
+
let timer = null;
|
|
36
|
+
let lastW = -1;
|
|
37
|
+
let lastH = -1;
|
|
38
|
+
let firstReported = false;
|
|
39
|
+
const report = (width, height) => {
|
|
40
|
+
const side = square ? Math.min(width, height) : 0;
|
|
41
|
+
const bw = bucket(square ? side : width);
|
|
42
|
+
const bh = bucket(square ? side : height);
|
|
43
|
+
if (bw === lastW && bh === lastH)
|
|
44
|
+
return;
|
|
45
|
+
lastW = bw;
|
|
46
|
+
lastH = bh;
|
|
47
|
+
displaySizes.report(flightId, instanceId, bw, bh);
|
|
48
|
+
};
|
|
49
|
+
const observer = new ResizeObserver((entries) => {
|
|
50
|
+
const entry = entries[0];
|
|
51
|
+
if (!entry)
|
|
52
|
+
return;
|
|
53
|
+
const { width, height } = entry.contentRect;
|
|
54
|
+
if (!firstReported) {
|
|
55
|
+
firstReported = true;
|
|
56
|
+
report(width, height);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (timer !== null)
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
timer = setTimeout(() => report(width, height), DEBOUNCE_MS);
|
|
62
|
+
});
|
|
63
|
+
observer.observe(el);
|
|
64
|
+
return () => {
|
|
65
|
+
observer.disconnect();
|
|
66
|
+
if (timer !== null)
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
displaySizes.clear(flightId, instanceId);
|
|
69
|
+
};
|
|
70
|
+
}, [displaySizes, flightId, ref, enabled, square, instanceId]);
|
|
71
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* is a peer to @ksp-gonogo/kerbcast (the core SDK) and requires React 18+
|
|
6
6
|
* and styled-components 6+ as peer dependencies.
|
|
7
7
|
*/
|
|
8
|
-
export { KerbcastProvider, createClientSubscriptions, useKerbcastClient, useKerbcastSubscriptions, } from "./context";
|
|
9
|
-
export type { KerbcastProviderProps, KerbcastSubscriptions } from "./context";
|
|
8
|
+
export { KerbcastProvider, createClientSubscriptions, createClientDisplaySizes, useKerbcastClient, useKerbcastSubscriptions, useKerbcastDisplaySizes, } from "./context";
|
|
9
|
+
export type { KerbcastProviderProps, KerbcastSubscriptions, KerbcastDisplaySizes, } from "./context";
|
|
10
10
|
export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
11
11
|
export { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
12
12
|
export { useKerbcastClock } from "./hooks/useKerbcastClock";
|
|
13
13
|
export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
14
|
+
export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
14
15
|
export { StandbyIcon } from "./StandbyIcon";
|
|
15
16
|
export { buildCameraLabeler } from "./cameraLabels";
|
|
16
17
|
export type { LabelableCamera } from "./cameraLabels";
|
|
@@ -18,3 +19,5 @@ export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
|
|
|
18
19
|
export type { CameraLifecycle } from "./lifecycle";
|
|
19
20
|
export { CameraFeed } from "./CameraFeed";
|
|
20
21
|
export type { CameraFeedHandle, CameraFeedProps, CameraStreamHook, FeedAction, } from "./CameraFeed";
|
|
22
|
+
export { KerbalFaceFeed } from "./KerbalFaceFeed";
|
|
23
|
+
export type { KerbalFaceFeedProps, KerbalFeedState } from "./KerbalFaceFeed";
|
package/dist/index.js
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
* and styled-components 6+ as peer dependencies.
|
|
7
7
|
*/
|
|
8
8
|
// Context and provider
|
|
9
|
-
export { KerbcastProvider, createClientSubscriptions, useKerbcastClient, useKerbcastSubscriptions, } from "./context";
|
|
9
|
+
export { KerbcastProvider, createClientSubscriptions, createClientDisplaySizes, useKerbcastClient, useKerbcastSubscriptions, useKerbcastDisplaySizes, } from "./context";
|
|
10
10
|
// Hooks
|
|
11
11
|
export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
12
12
|
export { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
13
13
|
export { useKerbcastClock } from "./hooks/useKerbcastClock";
|
|
14
14
|
export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
15
|
+
export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
15
16
|
// Shared standby glyph (used by CameraFeed + downstream dashboards)
|
|
16
17
|
export { StandbyIcon } from "./StandbyIcon";
|
|
17
18
|
// Camera label utilities
|
|
@@ -20,3 +21,5 @@ export { buildCameraLabeler } from "./cameraLabels";
|
|
|
20
21
|
export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
|
|
21
22
|
// CameraFeed component
|
|
22
23
|
export { CameraFeed } from "./CameraFeed";
|
|
24
|
+
// KerbalFaceFeed — shared single-kerbal-face primitive
|
|
25
|
+
export { KerbalFaceFeed } from "./KerbalFaceFeed";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ksp-gonogo/kerbcast-react",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "React components and hooks for kerbcast camera feeds.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jon Pepler",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"access": "public"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@ksp-gonogo/kerbcast": "^1.
|
|
48
|
+
"@ksp-gonogo/kerbcast": "^1.7.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"react": "^18 || ^19",
|
package/src/CameraFeed.test.tsx
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* - Station (brokered) mode -- gonogo-only KerbcastDataSource.attachBroker.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import { KerbcastClient, QualityPreset } from "@ksp-gonogo/kerbcast";
|
|
25
|
+
import { KerbcastClient, QualityPreset, TrackMode } from "@ksp-gonogo/kerbcast";
|
|
26
26
|
import type { CameraLifecycle, ClientMessage } from "@ksp-gonogo/kerbcast";
|
|
27
27
|
import { type MockCameraInit, MockSidecar } from "@ksp-gonogo/kerbcast/testing";
|
|
28
28
|
import {
|
|
@@ -108,6 +108,7 @@ function toInit(c: CameraStateLike): MockCameraInit {
|
|
|
108
108
|
degradeLevel: c.degradeLevel as number | undefined,
|
|
109
109
|
viewerQuality: c.viewerQuality as QualityPreset | undefined,
|
|
110
110
|
qualityLimitedBy: c.qualityLimitedBy as string | undefined,
|
|
111
|
+
trackMode: c.trackMode as TrackMode | undefined,
|
|
111
112
|
};
|
|
112
113
|
}
|
|
113
114
|
|
|
@@ -154,8 +155,10 @@ function renderFeed(
|
|
|
154
155
|
showDebugInfo?: boolean;
|
|
155
156
|
renderSize?: "auto" | "none";
|
|
156
157
|
enableQualityControl?: boolean;
|
|
158
|
+
enableTracking?: boolean;
|
|
157
159
|
showStatic?: boolean;
|
|
158
160
|
showStandbyIcon?: boolean;
|
|
161
|
+
showActions?: boolean;
|
|
159
162
|
ref?: React.Ref<CameraFeedHandle>;
|
|
160
163
|
},
|
|
161
164
|
) {
|
|
@@ -1351,28 +1354,61 @@ describe("CameraFeed - ResizeObserver render-size feedback", () => {
|
|
|
1351
1354
|
resizeCallback = undefined;
|
|
1352
1355
|
});
|
|
1353
1356
|
|
|
1354
|
-
it("
|
|
1357
|
+
it("reports the measured display size (report-display-size, real w x h)", async () => {
|
|
1355
1358
|
const { client, sidecar } = await buildConnectedSource();
|
|
1356
1359
|
|
|
1357
1360
|
renderFeed(client, { flightId: 42 });
|
|
1358
1361
|
|
|
1362
|
+
// First measurement reports promptly (no debounce wait).
|
|
1359
1363
|
await act(async () => {
|
|
1360
1364
|
resizeCallback?.(
|
|
1361
|
-
[{ contentRect: { width: 400, height:
|
|
1365
|
+
[{ contentRect: { width: 400, height: 224 } }] as ResizeObserverEntry[],
|
|
1362
1366
|
{} as ResizeObserver,
|
|
1363
1367
|
);
|
|
1364
1368
|
});
|
|
1365
1369
|
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1370
|
+
const msg = sidecar.lastCommand("report-display-size");
|
|
1371
|
+
expect(msg).toBeTruthy();
|
|
1372
|
+
// Measured w x h (no synthetic 16:9 crop), bucketed to a 16-multiple:
|
|
1373
|
+
// 400 -> 400, 224 -> 224.
|
|
1374
|
+
expect(msg?.content.width).toBe(400);
|
|
1375
|
+
expect(msg?.content.height).toBe(224);
|
|
1376
|
+
// Measurement no longer drives the operator set-render-size (that path is
|
|
1377
|
+
// the manual quality cap only).
|
|
1378
|
+
expect(sidecar.commands.some((c) => c.type === "set-render-size")).toBe(false);
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
it("does not report when renderSize is none", async () => {
|
|
1382
|
+
const { client, sidecar } = await buildConnectedSource();
|
|
1369
1383
|
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
//
|
|
1373
|
-
|
|
1374
|
-
expect(
|
|
1375
|
-
|
|
1384
|
+
renderFeed(client, { flightId: 42, renderSize: "none" });
|
|
1385
|
+
|
|
1386
|
+
// The observer is never armed when reporting is disabled.
|
|
1387
|
+
expect(resizeCallback).toBeUndefined();
|
|
1388
|
+
expect(sidecar.commands.some((c) => c.type === "report-display-size")).toBe(false);
|
|
1389
|
+
});
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
// ---------------------------------------------------------------------------
|
|
1393
|
+
// showActions
|
|
1394
|
+
// ---------------------------------------------------------------------------
|
|
1395
|
+
|
|
1396
|
+
describe("CameraFeed - showActions", () => {
|
|
1397
|
+
it("renders the action bar by default", async () => {
|
|
1398
|
+
const { client } = await buildConnectedSource();
|
|
1399
|
+
const { queryByLabelText } = renderFeed(client, { flightId: 42 });
|
|
1400
|
+
// The camera step buttons live in the action bar.
|
|
1401
|
+
expect(queryByLabelText("Next camera")).toBeTruthy();
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
it("hides the action bar when showActions is false", async () => {
|
|
1405
|
+
const { client } = await buildConnectedSource();
|
|
1406
|
+
const { queryByLabelText } = renderFeed(client, {
|
|
1407
|
+
flightId: 42,
|
|
1408
|
+
showActions: false,
|
|
1409
|
+
});
|
|
1410
|
+
expect(queryByLabelText("Next camera")).toBeNull();
|
|
1411
|
+
expect(queryByLabelText("Previous camera")).toBeNull();
|
|
1376
1412
|
});
|
|
1377
1413
|
});
|
|
1378
1414
|
|
|
@@ -1682,3 +1718,131 @@ describe("CameraFeed - client prop override", () => {
|
|
|
1682
1718
|
});
|
|
1683
1719
|
});
|
|
1684
1720
|
});
|
|
1721
|
+
|
|
1722
|
+
// ---------------------------------------------------------------------------
|
|
1723
|
+
// Tracking (crosshair) — auto-track a moving vessel, server-authoritative
|
|
1724
|
+
// ---------------------------------------------------------------------------
|
|
1725
|
+
|
|
1726
|
+
describe("CameraFeed - tracking (crosshair)", () => {
|
|
1727
|
+
function panZoomCam(overrides: Record<string, unknown> = {}) {
|
|
1728
|
+
return makeCamera({
|
|
1729
|
+
flightId: 42,
|
|
1730
|
+
cameraName: "Launchpad Cam",
|
|
1731
|
+
supportsPan: true,
|
|
1732
|
+
supportsZoom: true,
|
|
1733
|
+
panPitchMin: -90,
|
|
1734
|
+
panPitchMax: 90,
|
|
1735
|
+
...overrides,
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
it("shows the crosshair on a pan+zoom camera when enableTracking", async () => {
|
|
1740
|
+
const { client } = await buildConnectedSource([panZoomCam()]);
|
|
1741
|
+
const { queryByLabelText } = renderFeed(client, {
|
|
1742
|
+
flightId: 42,
|
|
1743
|
+
enableTracking: true,
|
|
1744
|
+
});
|
|
1745
|
+
expect(queryByLabelText("Auto-track")).toBeTruthy();
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
it("hides the crosshair without enableTracking", async () => {
|
|
1749
|
+
const { client } = await buildConnectedSource([panZoomCam()]);
|
|
1750
|
+
const { queryByLabelText } = renderFeed(client, { flightId: 42 });
|
|
1751
|
+
expect(queryByLabelText("Auto-track")).toBeNull();
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1754
|
+
it("hides the crosshair when the camera lacks pan or zoom", async () => {
|
|
1755
|
+
const { client } = await buildConnectedSource([
|
|
1756
|
+
makeCamera({ flightId: 42, supportsPan: true, supportsZoom: false }),
|
|
1757
|
+
]);
|
|
1758
|
+
const { queryByLabelText } = renderFeed(client, {
|
|
1759
|
+
flightId: 42,
|
|
1760
|
+
enableTracking: true,
|
|
1761
|
+
});
|
|
1762
|
+
expect(queryByLabelText("Auto-track")).toBeNull();
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
it("clicking a mode sends set-track-target with that mode", async () => {
|
|
1766
|
+
const { client, sidecar } = await buildConnectedSource([panZoomCam()]);
|
|
1767
|
+
const { getByLabelText } = renderFeed(client, {
|
|
1768
|
+
flightId: 42,
|
|
1769
|
+
enableTracking: true,
|
|
1770
|
+
});
|
|
1771
|
+
fireEvent.click(getByLabelText("Auto-track"));
|
|
1772
|
+
fireEvent.click(
|
|
1773
|
+
await screen.findByRole("menuitemradio", { name: "Track active vessel" }),
|
|
1774
|
+
);
|
|
1775
|
+
expect(sidecar.lastCommand("set-track-target", 42)?.content.mode).toBe(
|
|
1776
|
+
TrackMode.ActiveVessel,
|
|
1777
|
+
);
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
it("highlight reflects the SERVER trackMode, never the click (not optimistic)", async () => {
|
|
1781
|
+
const { client, sidecar } = await buildConnectedSource([panZoomCam()]);
|
|
1782
|
+
// Suppress the echo so a click alone cannot flip the highlight; only a
|
|
1783
|
+
// real server state change may.
|
|
1784
|
+
const spy = vi.spyOn(client, "setTrackTarget").mockResolvedValue();
|
|
1785
|
+
const { getByLabelText } = renderFeed(client, {
|
|
1786
|
+
flightId: 42,
|
|
1787
|
+
enableTracking: true,
|
|
1788
|
+
});
|
|
1789
|
+
expect(getByLabelText("Auto-track").getAttribute("aria-pressed")).toBe("false");
|
|
1790
|
+
|
|
1791
|
+
fireEvent.click(getByLabelText("Auto-track"));
|
|
1792
|
+
fireEvent.click(
|
|
1793
|
+
await screen.findByRole("menuitemradio", { name: "Track target" }),
|
|
1794
|
+
);
|
|
1795
|
+
expect(spy).toHaveBeenCalledWith(42, TrackMode.Target);
|
|
1796
|
+
// No echo yet -> highlight must stay off.
|
|
1797
|
+
expect(getByLabelText("Auto-track").getAttribute("aria-pressed")).toBe("false");
|
|
1798
|
+
|
|
1799
|
+
// Server confirms -> highlight turns on.
|
|
1800
|
+
await act(async () => {
|
|
1801
|
+
sidecar.updateCamera(42, { trackMode: TrackMode.Target });
|
|
1802
|
+
});
|
|
1803
|
+
expect(getByLabelText("Auto-track").getAttribute("aria-pressed")).toBe("true");
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
it("clicking the active mode deselects to none", async () => {
|
|
1807
|
+
const { client, sidecar } = await buildConnectedSource([
|
|
1808
|
+
panZoomCam({ trackMode: TrackMode.ActiveVessel }),
|
|
1809
|
+
]);
|
|
1810
|
+
const { getByLabelText } = renderFeed(client, {
|
|
1811
|
+
flightId: 42,
|
|
1812
|
+
enableTracking: true,
|
|
1813
|
+
});
|
|
1814
|
+
fireEvent.click(getByLabelText("Auto-track"));
|
|
1815
|
+
fireEvent.click(
|
|
1816
|
+
await screen.findByRole("menuitemradio", { name: "Track active vessel" }),
|
|
1817
|
+
);
|
|
1818
|
+
expect(sidecar.lastCommand("set-track-target", 42)?.content.mode).toBe(
|
|
1819
|
+
TrackMode.None,
|
|
1820
|
+
);
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
it("disables the manual pan + zoom controls while tracking", async () => {
|
|
1824
|
+
const { client } = await buildConnectedSource([
|
|
1825
|
+
panZoomCam({ trackMode: TrackMode.Target }),
|
|
1826
|
+
]);
|
|
1827
|
+
const { getByLabelText } = renderFeed(client, {
|
|
1828
|
+
flightId: 42,
|
|
1829
|
+
enableTracking: true,
|
|
1830
|
+
});
|
|
1831
|
+
expect((getByLabelText("Zoom in") as HTMLButtonElement).disabled).toBe(true);
|
|
1832
|
+
expect((getByLabelText("Zoom out") as HTMLButtonElement).disabled).toBe(true);
|
|
1833
|
+
expect((getByLabelText("Pan left") as HTMLButtonElement).disabled).toBe(true);
|
|
1834
|
+
expect((getByLabelText("Pan right") as HTMLButtonElement).disabled).toBe(true);
|
|
1835
|
+
});
|
|
1836
|
+
|
|
1837
|
+
it("keeps manual pan + zoom enabled when not tracking", async () => {
|
|
1838
|
+
const { client } = await buildConnectedSource([
|
|
1839
|
+
panZoomCam({ trackMode: TrackMode.None }),
|
|
1840
|
+
]);
|
|
1841
|
+
const { getByLabelText } = renderFeed(client, {
|
|
1842
|
+
flightId: 42,
|
|
1843
|
+
enableTracking: true,
|
|
1844
|
+
});
|
|
1845
|
+
expect((getByLabelText("Zoom in") as HTMLButtonElement).disabled).toBe(false);
|
|
1846
|
+
expect((getByLabelText("Pan left") as HTMLButtonElement).disabled).toBe(false);
|
|
1847
|
+
});
|
|
1848
|
+
});
|