@ksp-gonogo/kerbcast-react 1.6.3 → 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
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KerbalFaceFeed — the shared single-kerbal-face primitive.
|
|
3
|
+
*
|
|
4
|
+
* Renders ONE kerbal's face-camera stream in a fixed SQUARE frame. It is the
|
|
5
|
+
* reusable building block a crew bar (kerbcast web) or a mission-control
|
|
6
|
+
* surface (gonogo) composes: identity-keyed by `flightId`, it binds the live
|
|
7
|
+
* `MediaStream` to a persistent `<video>` and NEVER remounts that element on
|
|
8
|
+
* re-layout (the "never remount a live feed" rule — the same srcObject-via-ref
|
|
9
|
+
* technique `CameraFeed` uses).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately minimal chrome: just the square live face plus a standby glyph
|
|
12
|
+
* when there is no live signal. Everything else — name label, IVA/EVA badge,
|
|
13
|
+
* click behaviour, a "SIGNAL LOST" treatment — is layered OVER it by the
|
|
14
|
+
* wrapper via `children`, driven by the `onFeedStateChange` signal. A thin
|
|
15
|
+
* `actions` bar (the shared `FeedAction` shape) is rendered top-right when a
|
|
16
|
+
* wrapper supplies one, so declarative controls compose the same way they do
|
|
17
|
+
* on `CameraFeed`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useEffect, useRef, useState } from "react";
|
|
21
|
+
import styled from "styled-components";
|
|
22
|
+
|
|
23
|
+
import { FeedAction, CameraStreamHook } from "./CameraFeed";
|
|
24
|
+
import { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
25
|
+
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
26
|
+
import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
27
|
+
import { isCameraDestroyed } from "./lifecycle";
|
|
28
|
+
import { StandbyIcon } from "./StandbyIcon";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Coarse presentation state a wrapper reacts to for its own fallback/badge:
|
|
32
|
+
* - `live`: a stream is bound and playing.
|
|
33
|
+
* - `no-signal`: no stream yet (connecting, out of flight, or not visible).
|
|
34
|
+
* - `destroyed`: the camera's part/kerbal is gone (the wrapper renders SIGNAL
|
|
35
|
+
* LOST; the last frame stays on-screen because the `<video>` is not cleared).
|
|
36
|
+
*/
|
|
37
|
+
export type KerbalFeedState = "live" | "no-signal" | "destroyed";
|
|
38
|
+
|
|
39
|
+
export interface KerbalFaceFeedProps {
|
|
40
|
+
/** Wire-id (`CameraState.flightId`) of the kerbal face camera to display. */
|
|
41
|
+
flightId: number;
|
|
42
|
+
/**
|
|
43
|
+
* Explicit square side in px, for LAYOUT only. When omitted the feed fills
|
|
44
|
+
* its parent's width at a 1:1 aspect ratio. This does NOT drive resolution:
|
|
45
|
+
* the primitive always self-measures its rendered box and reports that to the
|
|
46
|
+
* sidecar (auto-resolution). Pass it to fix the element's size, not to
|
|
47
|
+
* request a stream resolution.
|
|
48
|
+
*/
|
|
49
|
+
size?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Report the self-measured display size to the sidecar to drive
|
|
52
|
+
* auto-resolution. Default true. Set false for a fixed-resolution feed that
|
|
53
|
+
* should not participate in auto-res. Independent of {@link showActions}
|
|
54
|
+
* (a resolution concern, not a UI one).
|
|
55
|
+
*/
|
|
56
|
+
reportSize?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Consumer action buttons, rendered as a minimal top-right bar (the shared
|
|
59
|
+
* {@link FeedAction} shape). Omit for no bar — the common case, since a crew
|
|
60
|
+
* bar usually composes its controls in `children` instead.
|
|
61
|
+
*/
|
|
62
|
+
actions?: FeedAction[];
|
|
63
|
+
/**
|
|
64
|
+
* Render the feed's action UI (the top-right action bar). Default true. Set
|
|
65
|
+
* false to suppress the bar entirely (e.g. a tiny avatar where hover controls
|
|
66
|
+
* add nothing). Does not affect display-size reporting.
|
|
67
|
+
*/
|
|
68
|
+
showActions?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Overlay content layered OVER the square face: name label, IVA/EVA badge,
|
|
71
|
+
* click target, a custom destroyed treatment. Absolutely positioned to fill
|
|
72
|
+
* the frame; the primitive keeps its own chrome out of the way.
|
|
73
|
+
*/
|
|
74
|
+
children?: React.ReactNode;
|
|
75
|
+
/**
|
|
76
|
+
* Fired whenever the derived feed state changes, so the wrapper can swap its
|
|
77
|
+
* fallback/badge without polling. Fires once on mount with the initial state.
|
|
78
|
+
*/
|
|
79
|
+
onFeedStateChange?: (state: KerbalFeedState) => void;
|
|
80
|
+
/**
|
|
81
|
+
* Draw the built-in standby glyph while not `live`. Default true. Set false
|
|
82
|
+
* when the wrapper renders its own out-of-signal overlay (so it isn't drawn
|
|
83
|
+
* twice); the frame then just stays dark / holds the last frame.
|
|
84
|
+
*/
|
|
85
|
+
showStandby?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Override how the stream is sourced (delayed playout, alternate transport).
|
|
88
|
+
* Must be a stable reference passed consistently across renders — it is
|
|
89
|
+
* called as a hook. Omit to use the built-in {@link useKerbcastStream}.
|
|
90
|
+
*/
|
|
91
|
+
useStream?: CameraStreamHook;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const Frame = styled.div<{ $size?: number }>`
|
|
95
|
+
position: relative;
|
|
96
|
+
${(p) => (p.$size != null ? `width: ${p.$size}px; height: ${p.$size}px;` : "width: 100%;")}
|
|
97
|
+
aspect-ratio: 1 / 1;
|
|
98
|
+
overflow: hidden;
|
|
99
|
+
background: #000;
|
|
100
|
+
border-radius: 4px;
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
const Face = styled.video`
|
|
104
|
+
width: 100%;
|
|
105
|
+
height: 100%;
|
|
106
|
+
/* Cover the square: the portrait fills the frame, cropping the excess,
|
|
107
|
+
rather than letterboxing — a face tile reads best edge-to-edge. */
|
|
108
|
+
object-fit: cover;
|
|
109
|
+
display: block;
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
const StandbyLayer = styled.div`
|
|
113
|
+
position: absolute;
|
|
114
|
+
inset: 0;
|
|
115
|
+
display: flex;
|
|
116
|
+
align-items: center;
|
|
117
|
+
justify-content: center;
|
|
118
|
+
color: rgba(255, 255, 255, 0.35);
|
|
119
|
+
pointer-events: none;
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
const DestroyedScrim = styled.div`
|
|
123
|
+
position: absolute;
|
|
124
|
+
inset: 0;
|
|
125
|
+
background: rgba(0, 0, 0, 0.45);
|
|
126
|
+
pointer-events: none;
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
const OverlayLayer = styled.div`
|
|
130
|
+
position: absolute;
|
|
131
|
+
inset: 0;
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
const ActionBar = styled.div`
|
|
135
|
+
position: absolute;
|
|
136
|
+
top: 4px;
|
|
137
|
+
right: 4px;
|
|
138
|
+
display: flex;
|
|
139
|
+
gap: 4px;
|
|
140
|
+
z-index: 2;
|
|
141
|
+
`;
|
|
142
|
+
|
|
143
|
+
const ActionButton = styled.button<{ $active?: boolean }>`
|
|
144
|
+
display: inline-flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
justify-content: center;
|
|
147
|
+
width: 22px;
|
|
148
|
+
height: 22px;
|
|
149
|
+
padding: 0;
|
|
150
|
+
border: none;
|
|
151
|
+
border-radius: 3px;
|
|
152
|
+
cursor: pointer;
|
|
153
|
+
color: #fff;
|
|
154
|
+
background: ${(p) => (p.$active ? "rgba(255,255,255,0.28)" : "rgba(0,0,0,0.45)")};
|
|
155
|
+
`;
|
|
156
|
+
|
|
157
|
+
export function KerbalFaceFeed({
|
|
158
|
+
flightId,
|
|
159
|
+
size,
|
|
160
|
+
reportSize = true,
|
|
161
|
+
actions,
|
|
162
|
+
showActions = true,
|
|
163
|
+
children,
|
|
164
|
+
onFeedStateChange,
|
|
165
|
+
showStandby = true,
|
|
166
|
+
useStream,
|
|
167
|
+
}: KerbalFaceFeedProps) {
|
|
168
|
+
// Match CameraFeed: the built-in hook (which acquires/releases the
|
|
169
|
+
// subscription slot) runs only when no override is supplied.
|
|
170
|
+
const resolveStream = useStream ?? useKerbcastStream;
|
|
171
|
+
const stream = resolveStream(flightId);
|
|
172
|
+
|
|
173
|
+
// The camera's lifecycle comes from the registry snapshot; destroyed cameras
|
|
174
|
+
// linger in the list so the wrapper can show SIGNAL LOST.
|
|
175
|
+
const cameras = useKerbcastCameras();
|
|
176
|
+
const destroyed = cameras.some((c) => c.flightId === flightId && isCameraDestroyed(c));
|
|
177
|
+
|
|
178
|
+
const state: KerbalFeedState = destroyed
|
|
179
|
+
? "destroyed"
|
|
180
|
+
: stream
|
|
181
|
+
? "live"
|
|
182
|
+
: "no-signal";
|
|
183
|
+
|
|
184
|
+
// Self-measure the square frame and report it to the sidecar (auto-res).
|
|
185
|
+
// Reporting follows the mount lifecycle; the registry collapses multiple
|
|
186
|
+
// feeds of the same kerbal to one MAX report.
|
|
187
|
+
const frameRef = useRef<HTMLDivElement>(null);
|
|
188
|
+
useReportDisplaySize(flightId, frameRef, { square: true, enabled: reportSize });
|
|
189
|
+
|
|
190
|
+
// Bind the stream to the persistent <video> without remounting it — the
|
|
191
|
+
// element stays mounted across every re-layout / prop change, only its
|
|
192
|
+
// srcObject is (re)assigned. A stall (stream -> null) leaves the last frame.
|
|
193
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (videoRef.current && stream) {
|
|
196
|
+
videoRef.current.srcObject = stream;
|
|
197
|
+
}
|
|
198
|
+
}, [stream]);
|
|
199
|
+
|
|
200
|
+
// Notify the wrapper on every state transition (and once on mount).
|
|
201
|
+
const notify = useRef(onFeedStateChange);
|
|
202
|
+
notify.current = onFeedStateChange;
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
notify.current?.(state);
|
|
205
|
+
}, [state]);
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<Frame ref={frameRef} $size={size} data-testid="kerbal-face-feed" data-feed-state={state}>
|
|
209
|
+
<Face ref={videoRef} autoPlay playsInline muted controls={false} />
|
|
210
|
+
{state === "destroyed" && <DestroyedScrim />}
|
|
211
|
+
{showStandby && state !== "live" && (
|
|
212
|
+
<StandbyLayer>
|
|
213
|
+
<StandbyIcon size={Math.round((size ?? 96) * 0.32)} />
|
|
214
|
+
</StandbyLayer>
|
|
215
|
+
)}
|
|
216
|
+
{showActions && actions && actions.length > 0 && (
|
|
217
|
+
<ActionBar>
|
|
218
|
+
{actions.map((a) => (
|
|
219
|
+
<ActionButton
|
|
220
|
+
key={a.id}
|
|
221
|
+
type="button"
|
|
222
|
+
aria-label={a.label}
|
|
223
|
+
title={a.label}
|
|
224
|
+
aria-pressed={a.active}
|
|
225
|
+
$active={a.active}
|
|
226
|
+
onClick={a.onClick}
|
|
227
|
+
>
|
|
228
|
+
{a.icon}
|
|
229
|
+
</ActionButton>
|
|
230
|
+
))}
|
|
231
|
+
</ActionBar>
|
|
232
|
+
)}
|
|
233
|
+
{children && <OverlayLayer>{children}</OverlayLayer>}
|
|
234
|
+
</Frame>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
3
|
+
import { createClientDisplaySizes } from "./context";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A client stub that only records reportDisplaySize calls. The registry is the
|
|
7
|
+
* per-consumer MAX collapse across every mounted reporter for one flightId, so
|
|
8
|
+
* a spotlight and a tiny avatar of the same camera resolve to one report = max,
|
|
9
|
+
* and a departing big reporter relaxes the stream back down (the v1.6.3
|
|
10
|
+
* "don't pin large" discipline at the client edge).
|
|
11
|
+
*/
|
|
12
|
+
function fakeClient() {
|
|
13
|
+
const reports: Array<{ flightId: number; width: number; height: number }> = [];
|
|
14
|
+
const client = {
|
|
15
|
+
reportDisplaySize: vi.fn(async (flightId: number, width: number, height: number) => {
|
|
16
|
+
reports.push({ flightId, width, height });
|
|
17
|
+
}),
|
|
18
|
+
} as unknown as KerbcastClient;
|
|
19
|
+
return { client, reports };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("createClientDisplaySizes", () => {
|
|
23
|
+
it("reports the measured size for a single reporter", () => {
|
|
24
|
+
const { client, reports } = fakeClient();
|
|
25
|
+
const reg = createClientDisplaySizes(client);
|
|
26
|
+
reg.report(1, "a", 40, 40);
|
|
27
|
+
expect(reports).toEqual([{ flightId: 1, width: 40, height: 40 }]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("collapses two reporters of one camera to a single MAX report", () => {
|
|
31
|
+
const { client, reports } = fakeClient();
|
|
32
|
+
const reg = createClientDisplaySizes(client);
|
|
33
|
+
reg.report(1, "small", 40, 40);
|
|
34
|
+
reg.report(1, "big", 512, 512);
|
|
35
|
+
// 40 first, then bumped to the max 512 when the big one mounts.
|
|
36
|
+
expect(reports).toEqual([
|
|
37
|
+
{ flightId: 1, width: 40, height: 40 },
|
|
38
|
+
{ flightId: 1, width: 512, height: 512 },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("maxes width and height independently across reporters", () => {
|
|
43
|
+
const { client, reports } = fakeClient();
|
|
44
|
+
const reg = createClientDisplaySizes(client);
|
|
45
|
+
reg.report(1, "a", 640, 360);
|
|
46
|
+
reg.report(1, "b", 320, 400);
|
|
47
|
+
expect(reports.at(-1)).toEqual({ flightId: 1, width: 640, height: 400 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not re-report when the max is unchanged", () => {
|
|
51
|
+
const { client, reports } = fakeClient();
|
|
52
|
+
const reg = createClientDisplaySizes(client);
|
|
53
|
+
reg.report(1, "big", 512, 512);
|
|
54
|
+
reg.report(1, "small", 40, 40); // below current max -> no new report
|
|
55
|
+
reg.report(1, "small", 44, 44); // still below -> no new report
|
|
56
|
+
expect(reports).toEqual([{ flightId: 1, width: 512, height: 512 }]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("relaxes to the next-largest reporter when the big one departs", () => {
|
|
60
|
+
const { client, reports } = fakeClient();
|
|
61
|
+
const reg = createClientDisplaySizes(client);
|
|
62
|
+
reg.report(1, "small", 40, 40);
|
|
63
|
+
reg.report(1, "big", 512, 512);
|
|
64
|
+
reg.clear(1, "big");
|
|
65
|
+
expect(reports.at(-1)).toEqual({ flightId: 1, width: 40, height: 40 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("sends nothing more once the last reporter departs (backend forget clears it)", () => {
|
|
69
|
+
const { client, reports } = fakeClient();
|
|
70
|
+
const reg = createClientDisplaySizes(client);
|
|
71
|
+
reg.report(1, "a", 40, 40);
|
|
72
|
+
const countBefore = reports.length;
|
|
73
|
+
reg.clear(1, "a");
|
|
74
|
+
expect(reports.length).toBe(countBefore); // no extra report on empty
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("keeps separate maxes per flightId", () => {
|
|
78
|
+
const { client, reports } = fakeClient();
|
|
79
|
+
const reg = createClientDisplaySizes(client);
|
|
80
|
+
reg.report(1, "a", 40, 40);
|
|
81
|
+
reg.report(2, "b", 512, 512);
|
|
82
|
+
expect(reports).toEqual([
|
|
83
|
+
{ flightId: 1, width: 40, height: 40 },
|
|
84
|
+
{ flightId: 2, width: 512, height: 512 },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("clearing an unknown reporter is a no-op", () => {
|
|
89
|
+
const { client, reports } = fakeClient();
|
|
90
|
+
const reg = createClientDisplaySizes(client);
|
|
91
|
+
reg.clear(99, "ghost");
|
|
92
|
+
expect(reports).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
});
|
package/src/context.tsx
CHANGED
|
@@ -46,6 +46,78 @@ export function createClientSubscriptions(
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Display-size reporting seam
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Collects each mounted feed's self-measured display size and drives the
|
|
55
|
+
* client's per-consumer `reportDisplaySize`. Multiple feeds can show the SAME
|
|
56
|
+
* camera at once (e.g. a tiny avatar and a spotlight of the same kerbal); the
|
|
57
|
+
* sidecar keys the auto-resolution max per CONSUMER, so those would clobber
|
|
58
|
+
* each other last-writer-wins. This collapses them at the client edge: it
|
|
59
|
+
* keeps the MAX display size across every mounted reporter for a flightId and
|
|
60
|
+
* sends one report per max-change. Co-located with the subscription refcount
|
|
61
|
+
* (same per-flightId lifetime) so one object owns everything this client is
|
|
62
|
+
* doing with camera N.
|
|
63
|
+
*/
|
|
64
|
+
export interface KerbcastDisplaySizes {
|
|
65
|
+
/** Register/update a reporter's measured size for a camera. */
|
|
66
|
+
report(flightId: number, instanceId: string, width: number, height: number): void;
|
|
67
|
+
/** Deregister a reporter (on unmount / flightId change). */
|
|
68
|
+
clear(flightId: number, instanceId: string): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Build the default per-flightId MAX display-size registry for a client. */
|
|
72
|
+
export function createClientDisplaySizes(
|
|
73
|
+
client: KerbcastClient,
|
|
74
|
+
): KerbcastDisplaySizes {
|
|
75
|
+
// flightId -> (instanceId -> measured size)
|
|
76
|
+
const perCamera = new Map<number, Map<string, { w: number; h: number }>>();
|
|
77
|
+
// Last size reported to the sidecar per flightId, so we send only on change.
|
|
78
|
+
const lastSent = new Map<number, { w: number; h: number }>();
|
|
79
|
+
|
|
80
|
+
function recompute(flightId: number): void {
|
|
81
|
+
const instances = perCamera.get(flightId);
|
|
82
|
+
if (!instances || instances.size === 0) {
|
|
83
|
+
// No reporters left. Drop bookkeeping and send nothing: the subscription
|
|
84
|
+
// release (unsubscribe -> sidecar remove_track -> forget_display_size)
|
|
85
|
+
// clears the sidecar side. Sending a spurious size here would fight it.
|
|
86
|
+
perCamera.delete(flightId);
|
|
87
|
+
lastSent.delete(flightId);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
let maxW = 0;
|
|
91
|
+
let maxH = 0;
|
|
92
|
+
for (const { w, h } of instances.values()) {
|
|
93
|
+
if (w > maxW) maxW = w;
|
|
94
|
+
if (h > maxH) maxH = h;
|
|
95
|
+
}
|
|
96
|
+
const prev = lastSent.get(flightId);
|
|
97
|
+
if (prev && prev.w === maxW && prev.h === maxH) return;
|
|
98
|
+
lastSent.set(flightId, { w: maxW, h: maxH });
|
|
99
|
+
client.reportDisplaySize(flightId, maxW, maxH).catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
report(flightId: number, instanceId: string, width: number, height: number): void {
|
|
104
|
+
let instances = perCamera.get(flightId);
|
|
105
|
+
if (!instances) {
|
|
106
|
+
instances = new Map();
|
|
107
|
+
perCamera.set(flightId, instances);
|
|
108
|
+
}
|
|
109
|
+
instances.set(instanceId, { w: width, h: height });
|
|
110
|
+
recompute(flightId);
|
|
111
|
+
},
|
|
112
|
+
clear(flightId: number, instanceId: string): void {
|
|
113
|
+
const instances = perCamera.get(flightId);
|
|
114
|
+
if (!instances) return;
|
|
115
|
+
instances.delete(instanceId);
|
|
116
|
+
recompute(flightId);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
49
121
|
// ---------------------------------------------------------------------------
|
|
50
122
|
// Context shape
|
|
51
123
|
// ---------------------------------------------------------------------------
|
|
@@ -53,6 +125,7 @@ export function createClientSubscriptions(
|
|
|
53
125
|
interface KerbcastContextValue {
|
|
54
126
|
client: KerbcastClient;
|
|
55
127
|
subscriptions: KerbcastSubscriptions;
|
|
128
|
+
displaySizes: KerbcastDisplaySizes;
|
|
56
129
|
}
|
|
57
130
|
|
|
58
131
|
const KerbcastContext = createContext<KerbcastContextValue | null>(null);
|
|
@@ -65,6 +138,8 @@ export interface KerbcastProviderProps {
|
|
|
65
138
|
client: KerbcastClient;
|
|
66
139
|
/** Override the subscription manager. Defaults to a refcounted per-client impl. */
|
|
67
140
|
subscriptions?: KerbcastSubscriptions;
|
|
141
|
+
/** Override the display-size registry. Defaults to a per-flightId MAX impl. */
|
|
142
|
+
displaySizes?: KerbcastDisplaySizes;
|
|
68
143
|
children: ReactNode;
|
|
69
144
|
}
|
|
70
145
|
|
|
@@ -75,6 +150,7 @@ export interface KerbcastProviderProps {
|
|
|
75
150
|
export function KerbcastProvider({
|
|
76
151
|
client,
|
|
77
152
|
subscriptions: subscriptionsProp,
|
|
153
|
+
displaySizes: displaySizesProp,
|
|
78
154
|
children,
|
|
79
155
|
}: KerbcastProviderProps): React.JSX.Element {
|
|
80
156
|
// Create default subscriptions once per client. A ref so the object is
|
|
@@ -84,13 +160,20 @@ export function KerbcastProvider({
|
|
|
84
160
|
defaultSubsRef.current = createClientSubscriptions(client);
|
|
85
161
|
}
|
|
86
162
|
|
|
163
|
+
// Same for the display-size registry: one per client, stable across renders.
|
|
164
|
+
const defaultSizesRef = useRef<KerbcastDisplaySizes | null>(null);
|
|
165
|
+
if (defaultSizesRef.current === null) {
|
|
166
|
+
defaultSizesRef.current = createClientDisplaySizes(client);
|
|
167
|
+
}
|
|
168
|
+
|
|
87
169
|
const value = useMemo(
|
|
88
170
|
() => ({
|
|
89
171
|
client,
|
|
90
172
|
subscriptions: subscriptionsProp ?? defaultSubsRef.current!,
|
|
173
|
+
displaySizes: displaySizesProp ?? defaultSizesRef.current!,
|
|
91
174
|
}),
|
|
92
175
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
93
|
-
[client, subscriptionsProp],
|
|
176
|
+
[client, subscriptionsProp, displaySizesProp],
|
|
94
177
|
);
|
|
95
178
|
|
|
96
179
|
return (
|
|
@@ -127,3 +210,17 @@ export function useKerbcastSubscriptions(): KerbcastSubscriptions {
|
|
|
127
210
|
}
|
|
128
211
|
return ctx.subscriptions;
|
|
129
212
|
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Return the `KerbcastDisplaySizes` registry from the nearest
|
|
216
|
+
* `KerbcastProvider`. Throws if called outside a provider.
|
|
217
|
+
*/
|
|
218
|
+
export function useKerbcastDisplaySizes(): KerbcastDisplaySizes {
|
|
219
|
+
const ctx = useContext(KerbcastContext);
|
|
220
|
+
if (!ctx) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
"useKerbcastDisplaySizes must be used inside a KerbcastProvider",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return ctx.displaySizes;
|
|
226
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useReportDisplaySize: the self-measure -> report path. Drives a
|
|
3
|
+
* controllable ResizeObserver and a recording displaySizes registry override
|
|
4
|
+
* so the debounce / bucket / prompt-first / square-vs-wh / clear-on-unmount
|
|
5
|
+
* behaviour is asserted directly, without a real layout engine.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
9
|
+
import { act, cleanup, render } from "@testing-library/react";
|
|
10
|
+
import { useRef } from "react";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { KerbcastProvider, type KerbcastDisplaySizes } from "../context";
|
|
14
|
+
import { useReportDisplaySize } from "./useReportDisplaySize";
|
|
15
|
+
|
|
16
|
+
// A controllable ResizeObserver: captures the callback so a test can fire a
|
|
17
|
+
// resize with a chosen contentRect.
|
|
18
|
+
let resizeCallback:
|
|
19
|
+
| ((entries: ResizeObserverEntry[], observer: ResizeObserver) => void)
|
|
20
|
+
| undefined;
|
|
21
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
22
|
+
|
|
23
|
+
// A recording registry override, injected via the provider.
|
|
24
|
+
function recordingRegistry() {
|
|
25
|
+
const reports: Array<{ flightId: number; id: string; w: number; h: number }> = [];
|
|
26
|
+
const clears: Array<{ flightId: number; id: string }> = [];
|
|
27
|
+
const displaySizes: KerbcastDisplaySizes = {
|
|
28
|
+
report: (flightId, id, w, h) => reports.push({ flightId, id, w, h }),
|
|
29
|
+
clear: (flightId, id) => clears.push({ flightId, id }),
|
|
30
|
+
};
|
|
31
|
+
return { displaySizes, reports, clears };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const fakeClient = {} as unknown as KerbcastClient;
|
|
35
|
+
|
|
36
|
+
function fireResize(width: number, height: number) {
|
|
37
|
+
act(() => {
|
|
38
|
+
resizeCallback?.(
|
|
39
|
+
[{ contentRect: { width, height } }] as ResizeObserverEntry[],
|
|
40
|
+
{} as ResizeObserver,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function Harness({
|
|
46
|
+
flightId,
|
|
47
|
+
square,
|
|
48
|
+
enabled,
|
|
49
|
+
}: {
|
|
50
|
+
flightId: number | null;
|
|
51
|
+
square?: boolean;
|
|
52
|
+
enabled?: boolean;
|
|
53
|
+
}) {
|
|
54
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
55
|
+
useReportDisplaySize(flightId, ref, { square, enabled });
|
|
56
|
+
return <div ref={ref} data-testid="feed" />;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.useFakeTimers();
|
|
61
|
+
globalThis.ResizeObserver = class {
|
|
62
|
+
constructor(cb: (entries: ResizeObserverEntry[], observer: ResizeObserver) => void) {
|
|
63
|
+
resizeCallback = cb;
|
|
64
|
+
}
|
|
65
|
+
observe() {}
|
|
66
|
+
unobserve() {}
|
|
67
|
+
disconnect() {}
|
|
68
|
+
} as unknown as typeof ResizeObserver;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
cleanup();
|
|
73
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
74
|
+
resizeCallback = undefined;
|
|
75
|
+
vi.useRealTimers();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("useReportDisplaySize", () => {
|
|
79
|
+
it("reports the first measurement promptly (no debounce wait)", () => {
|
|
80
|
+
const { displaySizes, reports } = recordingRegistry();
|
|
81
|
+
render(
|
|
82
|
+
<KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
|
|
83
|
+
<Harness flightId={7} />
|
|
84
|
+
</KerbcastProvider>,
|
|
85
|
+
);
|
|
86
|
+
// 40x30 -> ceil to a 16-multiple = 48x32, reported without advancing timers.
|
|
87
|
+
fireResize(40, 30);
|
|
88
|
+
expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 32 }]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("reports a square (min edge) when square is set", () => {
|
|
92
|
+
const { displaySizes, reports } = recordingRegistry();
|
|
93
|
+
render(
|
|
94
|
+
<KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
|
|
95
|
+
<Harness flightId={7} square />
|
|
96
|
+
</KerbcastProvider>,
|
|
97
|
+
);
|
|
98
|
+
// min(50,40)=40 -> 48x48 square.
|
|
99
|
+
fireResize(50, 40);
|
|
100
|
+
expect(reports.at(-1)).toMatchObject({ w: 48, h: 48 });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("debounces churn after the first report", () => {
|
|
104
|
+
const { displaySizes, reports } = recordingRegistry();
|
|
105
|
+
render(
|
|
106
|
+
<KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
|
|
107
|
+
<Harness flightId={7} />
|
|
108
|
+
</KerbcastProvider>,
|
|
109
|
+
);
|
|
110
|
+
fireResize(48, 48); // prompt first -> 48x48
|
|
111
|
+
fireResize(200, 200); // debounced, not yet applied
|
|
112
|
+
expect(reports).toHaveLength(1);
|
|
113
|
+
act(() => vi.advanceTimersByTime(200));
|
|
114
|
+
// 200 -> 208; the debounced resize now lands.
|
|
115
|
+
expect(reports.at(-1)).toMatchObject({ w: 208, h: 208 });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("does not re-report a sub-bucket change", () => {
|
|
119
|
+
const { displaySizes, reports } = recordingRegistry();
|
|
120
|
+
render(
|
|
121
|
+
<KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
|
|
122
|
+
<Harness flightId={7} />
|
|
123
|
+
</KerbcastProvider>,
|
|
124
|
+
);
|
|
125
|
+
fireResize(40, 40); // -> 48x48
|
|
126
|
+
fireResize(44, 44); // still ceils to 48x48 -> no new report
|
|
127
|
+
act(() => vi.advanceTimersByTime(200));
|
|
128
|
+
expect(reports).toHaveLength(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("clears its registration on unmount", () => {
|
|
132
|
+
const { displaySizes, reports, clears } = recordingRegistry();
|
|
133
|
+
const { unmount } = render(
|
|
134
|
+
<KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
|
|
135
|
+
<Harness flightId={7} />
|
|
136
|
+
</KerbcastProvider>,
|
|
137
|
+
);
|
|
138
|
+
fireResize(40, 40);
|
|
139
|
+
const id = reports[0].id;
|
|
140
|
+
act(() => unmount());
|
|
141
|
+
expect(clears).toEqual([{ flightId: 7, id }]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("does not observe or report when disabled", () => {
|
|
145
|
+
const { displaySizes, reports } = recordingRegistry();
|
|
146
|
+
render(
|
|
147
|
+
<KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
|
|
148
|
+
<Harness flightId={7} enabled={false} />
|
|
149
|
+
</KerbcastProvider>,
|
|
150
|
+
);
|
|
151
|
+
// The observer is never constructed when disabled, so no callback is armed.
|
|
152
|
+
expect(resizeCallback).toBeUndefined();
|
|
153
|
+
expect(reports).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("does nothing for a null flightId", () => {
|
|
157
|
+
const { displaySizes, reports } = recordingRegistry();
|
|
158
|
+
render(
|
|
159
|
+
<KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
|
|
160
|
+
<Harness flightId={null} />
|
|
161
|
+
</KerbcastProvider>,
|
|
162
|
+
);
|
|
163
|
+
expect(resizeCallback).toBeUndefined();
|
|
164
|
+
expect(reports).toEqual([]);
|
|
165
|
+
});
|
|
166
|
+
});
|