@ksp-gonogo/kerbcast-react 1.6.3 → 1.8.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +19 -0
  3. package/dist/CameraFeed.d.ts +63 -19
  4. package/dist/CameraFeed.js +329 -202
  5. package/dist/FeedActionBar.d.ts +133 -0
  6. package/dist/FeedActionBar.js +225 -0
  7. package/dist/KerbalFaceFeed.d.ts +82 -0
  8. package/dist/KerbalFaceFeed.js +111 -0
  9. package/dist/RecordingsTray.d.ts +37 -0
  10. package/dist/RecordingsTray.js +279 -0
  11. package/dist/context.d.ts +27 -1
  12. package/dist/context.js +68 -2
  13. package/dist/hooks/useRecordings.d.ts +35 -0
  14. package/dist/hooks/useRecordings.js +33 -0
  15. package/dist/hooks/useReportDisplaySize.d.ts +23 -0
  16. package/dist/hooks/useReportDisplaySize.js +71 -0
  17. package/dist/index.d.ts +12 -2
  18. package/dist/index.js +13 -1
  19. package/dist/menuPositioning.d.ts +20 -0
  20. package/dist/menuPositioning.js +98 -0
  21. package/dist/timing.d.ts +4 -0
  22. package/dist/timing.js +19 -0
  23. package/package.json +2 -2
  24. package/src/CameraFeed.test.tsx +719 -10
  25. package/src/CameraFeed.tsx +586 -334
  26. package/src/FeedActionBar.test.ts +196 -0
  27. package/src/FeedActionBar.test.tsx +215 -0
  28. package/src/FeedActionBar.tsx +391 -0
  29. package/src/KerbalFaceFeed.test.tsx +203 -0
  30. package/src/KerbalFaceFeed.tsx +214 -0
  31. package/src/RecordingsTray.test.tsx +288 -0
  32. package/src/RecordingsTray.tsx +475 -0
  33. package/src/context.test.tsx +94 -0
  34. package/src/context.tsx +98 -1
  35. package/src/hooks/useRecordings.sharedState.test.tsx +117 -0
  36. package/src/hooks/useRecordings.test.tsx +217 -0
  37. package/src/hooks/useRecordings.ts +110 -0
  38. package/src/hooks/useReportDisplaySize.test.tsx +166 -0
  39. package/src/hooks/useReportDisplaySize.ts +91 -0
  40. package/src/index.ts +27 -1
  41. package/src/menuPositioning.ts +126 -0
  42. package/src/timing.test.ts +34 -0
  43. package/src/timing.ts +21 -0
@@ -0,0 +1,117 @@
1
+ /*
2
+ * Regression test for the shared-state bug: two independent useRecordings()
3
+ * call sites over the same client must see the same controller state. Before
4
+ * the observable-store fix, each call site held its own useState mirror, so
5
+ * a clip started/stopped through one component's hook was invisible to
6
+ * another -- the exact scenario where a tile's own REC button starts a clip
7
+ * a RecordingsTray elsewhere can never reach (data loss).
8
+ *
9
+ * Must fail on the old per-instance-useState hook and pass once
10
+ * useRecordings consumes RecordingController via useSyncExternalStore.
11
+ */
12
+
13
+ import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
14
+ import { RecordingController, type RecordingClient } from "@ksp-gonogo/kerbcast";
15
+ import { installDomStubs, StubMediaRecorder } from "@ksp-gonogo/kerbcast/testing";
16
+ import { act, cleanup, render } from "@testing-library/react";
17
+ import { afterEach, describe, expect, it } from "vitest";
18
+ import { KerbcastProvider } from "../context";
19
+ import { useRecordings, type RecordingsStore } from "./useRecordings";
20
+
21
+ installDomStubs();
22
+
23
+ function liveStream(): MediaStream {
24
+ const track = { kind: "video" } as MediaStreamTrack;
25
+ return {
26
+ getVideoTracks: () => [track],
27
+ getTracks: () => [track],
28
+ } as unknown as MediaStream;
29
+ }
30
+
31
+ /** Fake RecordingClient with a settable clock; enough for the controller. */
32
+ class FakeRecordingClient implements RecordingClient {
33
+ captureUt: number | null = 100;
34
+ camera(_flightId: number): { readonly mediaStream: MediaStream | null } {
35
+ return { mediaStream: liveStream() };
36
+ }
37
+ get clock(): { readonly captureUt: number | null } {
38
+ return { captureUt: this.captureUt };
39
+ }
40
+ on(_event: "settings-change", _handler: (data: unknown) => void): () => void {
41
+ return () => {};
42
+ }
43
+ }
44
+
45
+ function fakeClient(): KerbcastClient {
46
+ const controller = new RecordingController(new FakeRecordingClient(), {
47
+ loadTrimmer: () => Promise.resolve(null),
48
+ });
49
+ return { recording: controller } as unknown as KerbcastClient;
50
+ }
51
+
52
+ let storeA: RecordingsStore | undefined;
53
+ let storeB: RecordingsStore | undefined;
54
+
55
+ /* Two components mounted side by side, each calling useRecordings() on its
56
+ own, standing in for a CameraFeed's own REC control and a separately
57
+ mounted RecordingsTray. */
58
+ function ProbeA(): null {
59
+ storeA = useRecordings();
60
+ return null;
61
+ }
62
+ function ProbeB(): null {
63
+ storeB = useRecordings();
64
+ return null;
65
+ }
66
+
67
+ function mount(client: KerbcastClient): void {
68
+ render(
69
+ <KerbcastProvider client={client}>
70
+ <ProbeA />
71
+ <ProbeB />
72
+ </KerbcastProvider>,
73
+ );
74
+ }
75
+
76
+ afterEach(() => {
77
+ cleanup();
78
+ storeA = undefined;
79
+ storeB = undefined;
80
+ StubMediaRecorder.supportedTypes = null;
81
+ });
82
+
83
+ describe("useRecordings shared store", () => {
84
+ it("makes an in-progress recording started via one call site visible to another", () => {
85
+ mount(fakeClient());
86
+
87
+ let id = "";
88
+ act(() => {
89
+ id = storeA!.start(42);
90
+ });
91
+
92
+ expect(storeB?.active).toHaveLength(1);
93
+ expect(storeB?.active[0]).toMatchObject({ flightId: 42, recordingId: id });
94
+ });
95
+
96
+ it("makes a clip finished via one call site's stop visible in another's recordings", async () => {
97
+ mount(fakeClient());
98
+
99
+ let id = "";
100
+ act(() => {
101
+ id = storeA!.start(42);
102
+ });
103
+
104
+ await act(async () => {
105
+ await storeA!.stop(id);
106
+ });
107
+
108
+ expect(storeA?.active).toHaveLength(0);
109
+ expect(storeA?.recordings).toHaveLength(1);
110
+
111
+ /* storeB never called start/stop itself: without the fix, this clip is
112
+ unreachable through it, which is exactly the data-loss bug. */
113
+ expect(storeB?.active).toHaveLength(0);
114
+ expect(storeB?.recordings).toHaveLength(1);
115
+ expect(storeB?.recordings[0].recordingId).toBe(id);
116
+ });
117
+ });
@@ -0,0 +1,217 @@
1
+ /*
2
+ * useRecordings store.
3
+ *
4
+ * Renders the hook through a real KerbcastProvider, backed by a fake client
5
+ * whose `recording` is a real RecordingController over a fake clock/camera
6
+ * surface (the SDK's own controller path is unit-tested in the typescript
7
+ * package). Asserts the store reflects start -> stop into `recordings` /
8
+ * `groups` / `active`, keeps grouped members out of the singles list, and
9
+ * clears on discard.
10
+ */
11
+
12
+ import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
13
+ import {
14
+ RecordingController,
15
+ type RecordingClient,
16
+ } from "@ksp-gonogo/kerbcast";
17
+ import { installDomStubs, StubMediaRecorder } from "@ksp-gonogo/kerbcast/testing";
18
+ import { act, cleanup, render } from "@testing-library/react";
19
+ import { afterEach, describe, expect, it } from "vitest";
20
+ import { KerbcastProvider } from "../context";
21
+ import { useRecordings, type RecordingsStore } from "./useRecordings";
22
+
23
+ installDomStubs();
24
+
25
+ function liveStream(): MediaStream {
26
+ const track = { kind: "video" } as MediaStreamTrack;
27
+ return {
28
+ getVideoTracks: () => [track],
29
+ getTracks: () => [track],
30
+ } as unknown as MediaStream;
31
+ }
32
+
33
+ /** Fake RecordingClient with a settable clock; enough for the controller. */
34
+ class FakeRecordingClient implements RecordingClient {
35
+ captureUt: number | null = 100;
36
+ camera(_flightId: number): { readonly mediaStream: MediaStream | null } {
37
+ return { mediaStream: liveStream() };
38
+ }
39
+ get clock(): { readonly captureUt: number | null } {
40
+ return { captureUt: this.captureUt };
41
+ }
42
+ on(_event: "settings-change", _handler: (data: unknown) => void): () => void {
43
+ return () => {};
44
+ }
45
+ }
46
+
47
+ /** A KerbcastClient stand-in exposing only what the hook reads: `.recording`.
48
+ * The trimmer loader is stubbed to null so grouped stops stay hermetic (the
49
+ * physical-trim path itself is covered in the typescript package). */
50
+ function fakeClient(): KerbcastClient {
51
+ const controller = new RecordingController(new FakeRecordingClient(), {
52
+ loadTrimmer: () => Promise.resolve(null),
53
+ });
54
+ return { recording: controller } as unknown as KerbcastClient;
55
+ }
56
+
57
+ let store: RecordingsStore | undefined;
58
+ function Probe(): null {
59
+ store = useRecordings();
60
+ return null;
61
+ }
62
+
63
+ function mount(client: KerbcastClient): void {
64
+ render(
65
+ <KerbcastProvider client={client}>
66
+ <Probe />
67
+ </KerbcastProvider>,
68
+ );
69
+ }
70
+
71
+ afterEach(() => {
72
+ cleanup();
73
+ store = undefined;
74
+ StubMediaRecorder.supportedTypes = null;
75
+ });
76
+
77
+ describe("useRecordings", () => {
78
+ it("starts empty", () => {
79
+ mount(fakeClient());
80
+ expect(store?.recordings).toEqual([]);
81
+ expect(store?.groups).toEqual([]);
82
+ expect(store?.active).toEqual([]);
83
+ });
84
+
85
+ it("tracks an active recording, then lands the clip in recordings on stop", async () => {
86
+ mount(fakeClient());
87
+
88
+ let id = "";
89
+ act(() => {
90
+ id = store!.start(42);
91
+ });
92
+ expect(store?.active).toHaveLength(1);
93
+ expect(store?.active[0]).toMatchObject({ flightId: 42, recordingId: id });
94
+
95
+ await act(async () => {
96
+ await store!.stop(id);
97
+ });
98
+ expect(store?.active).toHaveLength(0);
99
+ expect(store?.recordings).toHaveLength(1);
100
+ expect(store?.recordings[0].recordingId).toBe(id);
101
+ expect(store?.recordings[0].flightId).toBe(42);
102
+ });
103
+
104
+ it("keeps grouped clips out of the singles list and in groups", async () => {
105
+ mount(fakeClient());
106
+
107
+ let groupId = "";
108
+ act(() => {
109
+ groupId = store!.startGroup([1, 2]);
110
+ });
111
+ expect(store?.active).toHaveLength(2);
112
+ expect(store?.active.every((a) => a.groupId === groupId)).toBe(true);
113
+
114
+ await act(async () => {
115
+ await store!.stopGroup(groupId);
116
+ });
117
+ expect(store?.active).toHaveLength(0);
118
+ expect(store?.recordings).toHaveLength(0);
119
+ expect(store?.groups).toHaveLength(1);
120
+ expect(store?.groups[0].recordings).toHaveLength(2);
121
+ });
122
+
123
+ it("gives each active group member the real controller-minted recordingId, not a fabricated one", () => {
124
+ const client = fakeClient();
125
+ mount(client);
126
+
127
+ let groupId = "";
128
+ act(() => {
129
+ groupId = store!.startGroup([1, 2]);
130
+ });
131
+
132
+ for (const member of store!.active) {
133
+ expect(member.groupId).toBe(groupId);
134
+ /* Not the old `${groupId}:${flightId}` fabrication. */
135
+ expect(member.recordingId).not.toBe(`${groupId}:${member.flightId}`);
136
+ /* A real id the controller actually tracks. */
137
+ expect(client.recording.fetchRecording(member.recordingId)).toBeUndefined();
138
+ expect(client.recording.isRecording(member.flightId)).toBe(true);
139
+ }
140
+ });
141
+
142
+ it("cancels a single active group member without disturbing the rest of the group", async () => {
143
+ const client = fakeClient();
144
+ mount(client);
145
+
146
+ let groupId = "";
147
+ act(() => {
148
+ groupId = store!.startGroup([1, 2]);
149
+ });
150
+ const [first, second] = store!.active;
151
+
152
+ await act(async () => {
153
+ await store!.stop(first.recordingId);
154
+ });
155
+
156
+ /* The cancelled member's feed actually unlocks. */
157
+ expect(client.recording.isRecording(first.flightId)).toBe(false);
158
+ /* The other member is untouched: still active, still recording. */
159
+ expect(client.recording.isRecording(second.flightId)).toBe(true);
160
+ expect(store?.active).toHaveLength(1);
161
+ expect(store?.active[0].recordingId).toBe(second.recordingId);
162
+ });
163
+
164
+ it("discards a single active group member without disturbing the rest of the group", () => {
165
+ const client = fakeClient();
166
+ mount(client);
167
+
168
+ let groupId = "";
169
+ act(() => {
170
+ groupId = store!.startGroup([1, 2]);
171
+ });
172
+ const [first, second] = store!.active;
173
+
174
+ act(() => {
175
+ store!.discard(first.recordingId);
176
+ });
177
+
178
+ expect(client.recording.isRecording(first.flightId)).toBe(false);
179
+ expect(client.recording.isRecording(second.flightId)).toBe(true);
180
+ expect(store?.active).toHaveLength(1);
181
+ expect(store?.active[0].recordingId).toBe(second.recordingId);
182
+ });
183
+
184
+ it("discards a finished single clip", async () => {
185
+ mount(fakeClient());
186
+ let id = "";
187
+ act(() => {
188
+ id = store!.start(7);
189
+ });
190
+ await act(async () => {
191
+ await store!.stop(id);
192
+ });
193
+ expect(store?.recordings).toHaveLength(1);
194
+
195
+ act(() => {
196
+ store!.discard(id);
197
+ });
198
+ expect(store?.recordings).toHaveLength(0);
199
+ });
200
+
201
+ it("discards a finished grouped set", async () => {
202
+ mount(fakeClient());
203
+ let groupId = "";
204
+ act(() => {
205
+ groupId = store!.startGroup([1, 2]);
206
+ });
207
+ await act(async () => {
208
+ await store!.stopGroup(groupId);
209
+ });
210
+ expect(store?.groups).toHaveLength(1);
211
+
212
+ act(() => {
213
+ store!.discardGroup(groupId);
214
+ });
215
+ expect(store?.groups).toHaveLength(0);
216
+ });
217
+ });
@@ -0,0 +1,110 @@
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+ import type {
3
+ ActiveRecordingInfo,
4
+ GroupedRecordingHandle,
5
+ RecordingHandle,
6
+ StartRecordingOptions,
7
+ StopGroupedRecordingOptions,
8
+ } from "@ksp-gonogo/kerbcast";
9
+ import { useKerbcastClient } from "../context";
10
+
11
+ /* Re-exported for compatibility: consumers importing ActiveRecordingInfo from
12
+ this module keep working now that the SDK owns the definition. */
13
+ export type { ActiveRecordingInfo };
14
+
15
+ /**
16
+ * Reactive recordings store over the SDK's `client.recording` controller.
17
+ *
18
+ * Holds the in-session, in-memory clips: standalone singles in `recordings`,
19
+ * synchronised sets in `groups`, and the still-running ones in `active`. This
20
+ * is the headless data layer the recordings tray + REC controls sit on; it
21
+ * owns no chrome. All state is in-memory, so a page reload discards it (the UI
22
+ * guards navigation while unsaved clips exist).
23
+ */
24
+ export interface RecordingsStore {
25
+ /** Finished standalone (non-grouped) clips, newest last. */
26
+ recordings: RecordingHandle[];
27
+ /** Finished grouped sets, newest last. */
28
+ groups: GroupedRecordingHandle[];
29
+ /** Recordings currently in progress (single + grouped members). */
30
+ active: ActiveRecordingInfo[];
31
+ /** Whether a feed currently has a recording in progress. */
32
+ isRecording(flightId: number): boolean;
33
+
34
+ start(flightId: number, opts?: StartRecordingOptions): string;
35
+ stop(recordingId: string): Promise<RecordingHandle>;
36
+ startGroup(flightIds: number[], opts?: StartRecordingOptions): string;
37
+ stopGroup(
38
+ groupId: string,
39
+ opts?: StopGroupedRecordingOptions,
40
+ ): Promise<GroupedRecordingHandle>;
41
+ discard(recordingId: string): void;
42
+ discardGroup(groupId: string): void;
43
+ }
44
+
45
+ /**
46
+ * Subscribe a component to the recording store for the context client. The
47
+ * controller itself is the single source of truth for every call site;
48
+ * `useSyncExternalStore` re-renders this component whenever the controller's
49
+ * snapshot changes, no matter which call site (or which component) drove the
50
+ * change.
51
+ */
52
+ export function useRecordings(): RecordingsStore {
53
+ const client = useKerbcastClient();
54
+ const controller = client.recording;
55
+
56
+ const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
57
+
58
+ const start = useCallback(
59
+ (flightId: number, opts?: StartRecordingOptions): string =>
60
+ controller.startRecording(flightId, opts),
61
+ [controller],
62
+ );
63
+
64
+ const stop = useCallback(
65
+ (recordingId: string): Promise<RecordingHandle> => controller.stopRecording(recordingId),
66
+ [controller],
67
+ );
68
+
69
+ const startGroup = useCallback(
70
+ (flightIds: number[], opts?: StartRecordingOptions): string =>
71
+ controller.startGroupedRecording(flightIds, opts).groupId,
72
+ [controller],
73
+ );
74
+
75
+ const stopGroup = useCallback(
76
+ (
77
+ groupId: string,
78
+ opts?: StopGroupedRecordingOptions,
79
+ ): Promise<GroupedRecordingHandle> => controller.stopGroupedRecording(groupId, opts),
80
+ [controller],
81
+ );
82
+
83
+ const discard = useCallback(
84
+ (recordingId: string): void => controller.discardRecording(recordingId),
85
+ [controller],
86
+ );
87
+
88
+ const discardGroup = useCallback(
89
+ (groupId: string): void => controller.discardGroupedRecording(groupId),
90
+ [controller],
91
+ );
92
+
93
+ const isRecording = useCallback(
94
+ (flightId: number): boolean => controller.isRecording(flightId),
95
+ [controller],
96
+ );
97
+
98
+ return {
99
+ recordings: snapshot.recordings,
100
+ groups: snapshot.groups,
101
+ active: snapshot.active,
102
+ isRecording,
103
+ start,
104
+ stop,
105
+ startGroup,
106
+ stopGroup,
107
+ discard,
108
+ discardGroup,
109
+ };
110
+ }
@@ -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
+ });
@@ -0,0 +1,91 @@
1
+ import { useEffect, useId } from "react";
2
+ import { useKerbcastDisplaySizes } from "../context";
3
+
4
+ /*
5
+ * Debounce + bucket for self-measured display-size reporting. The first
6
+ * measurement after mount reports promptly (so a feed never sits at a stale
7
+ * resolution until it is resized); later churn is debounced. Sizes are ceiled
8
+ * to a BUCKET_PX multiple: that meets-the-minimum-need (never under-serve),
9
+ * yields an even value (H.264-friendly), and gives a material-change threshold
10
+ * so a feed re-reports only when a resize crosses a bucket boundary.
11
+ */
12
+ const DEBOUNCE_MS = 200;
13
+ const BUCKET_PX = 16;
14
+
15
+ function bucket(px: number): number {
16
+ const b = Math.ceil(px / BUCKET_PX) * BUCKET_PX;
17
+ return b < BUCKET_PX ? BUCKET_PX : b;
18
+ }
19
+
20
+ export interface ReportDisplaySizeOptions {
21
+ /**
22
+ * Report while mounted. Default true. Set false for a fixed-resolution feed
23
+ * that should not drive auto-resolution. This is a RESOLUTION concern only,
24
+ * independent of whether the feed shows its action UI.
25
+ */
26
+ enabled?: boolean;
27
+ /**
28
+ * Report a square (side x side, the min edge) rather than the raw w x h.
29
+ * Face cams are square; part cams are not.
30
+ */
31
+ square?: boolean;
32
+ }
33
+
34
+ /**
35
+ * Self-measure a feed element and drive the client's per-consumer
36
+ * `reportDisplaySize` (via the context display-size registry, which collapses
37
+ * multiple feeds of the same camera to one MAX report). Attach `ref` to the
38
+ * element whose rendered pixel box is the display size. Reporting follows the
39
+ * mount lifecycle: on unmount / `flightId` change / `enabled` flip it
40
+ * deregisters, and the sidecar's clear-on-departure (unsubscribe) does the
41
+ * server-side clear.
42
+ */
43
+ export function useReportDisplaySize(
44
+ flightId: number | null,
45
+ ref: React.RefObject<Element | null>,
46
+ { enabled = true, square = false }: ReportDisplaySizeOptions = {},
47
+ ): void {
48
+ const displaySizes = useKerbcastDisplaySizes();
49
+ const instanceId = useId();
50
+
51
+ useEffect(() => {
52
+ if (flightId === null || !enabled) return;
53
+ const el = ref.current;
54
+ if (!el) return;
55
+
56
+ let timer: ReturnType<typeof setTimeout> | null = null;
57
+ let lastW = -1;
58
+ let lastH = -1;
59
+ let firstReported = false;
60
+
61
+ const report = (width: number, height: number) => {
62
+ const side = square ? Math.min(width, height) : 0;
63
+ const bw = bucket(square ? side : width);
64
+ const bh = bucket(square ? side : height);
65
+ if (bw === lastW && bh === lastH) return;
66
+ lastW = bw;
67
+ lastH = bh;
68
+ displaySizes.report(flightId, instanceId, bw, bh);
69
+ };
70
+
71
+ const observer = new ResizeObserver((entries) => {
72
+ const entry = entries[0];
73
+ if (!entry) return;
74
+ const { width, height } = entry.contentRect;
75
+ if (!firstReported) {
76
+ firstReported = true;
77
+ report(width, height);
78
+ return;
79
+ }
80
+ if (timer !== null) clearTimeout(timer);
81
+ timer = setTimeout(() => report(width, height), DEBOUNCE_MS);
82
+ });
83
+ observer.observe(el);
84
+
85
+ return () => {
86
+ observer.disconnect();
87
+ if (timer !== null) clearTimeout(timer);
88
+ displaySizes.clear(flightId, instanceId);
89
+ };
90
+ }, [displaySizes, flightId, ref, enabled, square, instanceId]);
91
+ }