@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,475 @@
1
+ /**
2
+ * RecordingsTray: a presentational drawer over the client-side recordings
3
+ * store (`useRecordings()`). Lists finished single clips and grouped SETS,
4
+ * with a play/preview, a download, and a discard per clip; a group also gets
5
+ * a download-all + a discard-set. Recording is client-local and in-memory (no
6
+ * sidecar, no server state), so this is where an operator saves a clip before
7
+ * a page reload discards it. The tray shows a quiet reminder of that.
8
+ *
9
+ * Deliberately dumb: it owns no recording logic, only formatting and the
10
+ * per-clip object-URL lifecycle (create on first render, revoke on discard or
11
+ * unmount). Starting/stopping recordings is CameraFeed's REC control (or a
12
+ * host's own REC+ grouped flow); this component only ever reads the store and
13
+ * calls its discard/discardGroup actions.
14
+ *
15
+ * Every `RecordingHandle` (standalone or a grouped member) carries its own
16
+ * `flightId`, so camera names come straight off the clip, with no dependency
17
+ * on the store's transient `active` list. That matters because the tray
18
+ * commonly mounts after a recording has already finished (its `active` entry
19
+ * long gone by then). A host supplies the flightId -> label step via
20
+ * `cameraLabel` (e.g. `buildCameraLabeler(useKerbcastCameras())`, the same
21
+ * labeller CameraFeed uses) so this stays decoupled from any particular
22
+ * camera registry; omit it for a plain "Camera <flightId>" fallback.
23
+ */
24
+
25
+ import type { GroupedRecordingHandle, RecordingHandle } from "@ksp-gonogo/kerbcast";
26
+ import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
27
+ import styled from "styled-components";
28
+ import { useRecordings } from "./hooks/useRecordings";
29
+
30
+ export interface RecordingsTrayProps {
31
+ /**
32
+ * Resolve a flightId to a display label for a clip's camera name. Omit for
33
+ * a plain "Camera <flightId>" fallback.
34
+ */
35
+ cameraLabel?: (flightId: number) => string;
36
+ /** Forwarded to the root panel; lets a host lay out/size the tray. */
37
+ className?: string;
38
+ }
39
+
40
+ /**
41
+ * The shared drawer. Requires a `KerbcastProvider` ancestor (it calls
42
+ * `useRecordings()` directly, same requirement as `CameraFeed`).
43
+ */
44
+ export function RecordingsTray({ cameraLabel, className }: RecordingsTrayProps) {
45
+ const store = useRecordings();
46
+
47
+ const labelFor = useCallback(
48
+ (flightId: number): string => (cameraLabel ? cameraLabel(flightId) : `Camera ${flightId}`),
49
+ [cameraLabel],
50
+ );
51
+
52
+ const allClips = useMemo(
53
+ () => [...store.recordings, ...store.groups.flatMap((g) => g.recordings)],
54
+ [store.recordings, store.groups],
55
+ );
56
+ const objectUrls = useClipObjectUrls(allClips);
57
+
58
+ const hasAny = store.recordings.length > 0 || store.groups.length > 0;
59
+
60
+ return (
61
+ <Tray className={className} aria-label="Recordings">
62
+ {!hasAny ? (
63
+ <Empty>No recordings yet. Press REC on a feed to start one.</Empty>
64
+ ) : (
65
+ <>
66
+ <Notice>
67
+ Unsaved clips are lost on reload. Download what you want to keep.
68
+ </Notice>
69
+ <ClipList>
70
+ {store.recordings.map((rec) => (
71
+ <ClipRow
72
+ key={rec.recordingId}
73
+ rec={rec}
74
+ label={labelFor(rec.flightId)}
75
+ url={objectUrls.get(rec.recordingId)}
76
+ onDiscard={() => store.discard(rec.recordingId)}
77
+ />
78
+ ))}
79
+ {store.groups.map((group) => (
80
+ <GroupItem
81
+ key={group.groupId}
82
+ group={group}
83
+ labelFor={labelFor}
84
+ objectUrls={objectUrls}
85
+ onDiscardGroup={() => store.discardGroup(group.groupId)}
86
+ />
87
+ ))}
88
+ </ClipList>
89
+ </>
90
+ )}
91
+ </Tray>
92
+ );
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Sub-rows
97
+ // ---------------------------------------------------------------------------
98
+
99
+ interface ClipRowProps {
100
+ rec: RecordingHandle;
101
+ label: string;
102
+ url: string | undefined;
103
+ /** Omitted for a clip inside a grouped set: only the whole set discards. */
104
+ onDiscard?: () => void;
105
+ }
106
+
107
+ function ClipRow({ rec, label, url, onDiscard }: ClipRowProps) {
108
+ const utRange =
109
+ rec.utStart != null && rec.utEnd != null
110
+ ? `${formatUt(rec.utStart)} - ${formatUt(rec.utEnd)}`
111
+ : "no UT (out of flight)";
112
+
113
+ return (
114
+ <Clip>
115
+ {url ? (
116
+ <ClipPreview controls preload="metadata" src={url} aria-label={`Preview: ${label}`} />
117
+ ) : (
118
+ <ClipPreviewPlaceholder aria-hidden="true" />
119
+ )}
120
+ <ClipMeta>
121
+ <ClipName>{label}</ClipName>
122
+ <ClipDetail>{utRange}</ClipDetail>
123
+ <ClipDetail>
124
+ {formatDuration(rec.durationMs)} &middot; {formatBytes(rec.byteSize)}
125
+ </ClipDetail>
126
+ </ClipMeta>
127
+ <ClipActions>
128
+ {url && (
129
+ <TrayLink href={url} download={clipFileName(rec, label)}>
130
+ Download
131
+ </TrayLink>
132
+ )}
133
+ {onDiscard && (
134
+ <TrayButton type="button" $danger onClick={onDiscard}>
135
+ Discard
136
+ </TrayButton>
137
+ )}
138
+ </ClipActions>
139
+ </Clip>
140
+ );
141
+ }
142
+
143
+ interface GroupItemProps {
144
+ group: GroupedRecordingHandle;
145
+ labelFor: (flightId: number) => string;
146
+ objectUrls: Map<string, string>;
147
+ onDiscardGroup: () => void;
148
+ }
149
+
150
+ function GroupItem({ group, labelFor, objectUrls, onDiscardGroup }: GroupItemProps) {
151
+ return (
152
+ <GroupCard aria-label={`Grouped recording, ${group.recordings.length} feeds`}>
153
+ <GroupHeader>
154
+ <GroupHeaderText>
155
+ <GroupTitle>
156
+ Grouped recording &middot; {group.recordings.length} feeds
157
+ </GroupTitle>
158
+ {group.commonUtWindow && (
159
+ <GroupMeta>
160
+ common UT {formatUt(group.commonUtWindow[0])} -{" "}
161
+ {formatUt(group.commonUtWindow[1])}
162
+ </GroupMeta>
163
+ )}
164
+ </GroupHeaderText>
165
+ <GroupActions>
166
+ <TrayButton
167
+ type="button"
168
+ onClick={() => downloadAll(group.recordings, objectUrls, labelFor)}
169
+ >
170
+ Download all
171
+ </TrayButton>
172
+ <TrayButton type="button" $danger onClick={onDiscardGroup}>
173
+ Discard set
174
+ </TrayButton>
175
+ </GroupActions>
176
+ </GroupHeader>
177
+ {group.recordings.map((rec) => (
178
+ <ClipRow
179
+ key={rec.recordingId}
180
+ rec={rec}
181
+ label={labelFor(rec.flightId)}
182
+ url={objectUrls.get(rec.recordingId)}
183
+ />
184
+ ))}
185
+ </GroupCard>
186
+ );
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Object-URL lifecycle
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Creates one `URL.createObjectURL` per clip and revokes it once the clip
195
+ * disappears from `clips` (discarded) or on unmount. The map itself lives in
196
+ * a ref (not state) so the unmount cleanup always sees every URL ever
197
+ * created, even ones added after the effect that captured it first ran; a
198
+ * cheap reducer bump re-renders the caller whenever the map actually changes.
199
+ */
200
+ function useClipObjectUrls(clips: RecordingHandle[]): Map<string, string> {
201
+ const mapRef = useRef(new Map<string, string>());
202
+ const [, bump] = useReducer((c: number) => c + 1, 0);
203
+
204
+ useEffect(() => {
205
+ const map = mapRef.current;
206
+ const liveIds = new Set(clips.map((c) => c.recordingId));
207
+ let changed = false;
208
+
209
+ for (const [id, url] of map) {
210
+ if (!liveIds.has(id)) {
211
+ URL.revokeObjectURL(url);
212
+ map.delete(id);
213
+ changed = true;
214
+ }
215
+ }
216
+ for (const clip of clips) {
217
+ if (!map.has(clip.recordingId)) {
218
+ map.set(clip.recordingId, URL.createObjectURL(clip.blob));
219
+ changed = true;
220
+ }
221
+ }
222
+ if (changed) bump();
223
+ }, [clips]);
224
+
225
+ useEffect(() => {
226
+ const map = mapRef.current;
227
+ return () => {
228
+ for (const url of map.values()) URL.revokeObjectURL(url);
229
+ map.clear();
230
+ };
231
+ }, []);
232
+
233
+ return mapRef.current;
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Formatting + download helpers
238
+ // ---------------------------------------------------------------------------
239
+
240
+ /* KSP mission time (seconds); the tray shows the raw value rather than the
241
+ in-game calendar breakdown, which needs the epoch this component doesn't have. */
242
+ function formatUt(ut: number): string {
243
+ return `T+${Math.round(ut)}s`;
244
+ }
245
+
246
+ function formatDuration(ms: number): string {
247
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
248
+ const hours = Math.floor(totalSeconds / 3600);
249
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
250
+ const seconds = totalSeconds % 60;
251
+ const mm = hours > 0 ? minutes.toString().padStart(2, "0") : minutes.toString();
252
+ const ss = seconds.toString().padStart(2, "0");
253
+ return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
254
+ }
255
+
256
+ function formatBytes(bytes: number): string {
257
+ if (bytes < 1024) return `${bytes} B`;
258
+ const kb = bytes / 1024;
259
+ if (kb < 1024) return `${kb.toFixed(1)} KB`;
260
+ return `${(kb / 1024).toFixed(1)} MB`;
261
+ }
262
+
263
+ function clipFileName(rec: RecordingHandle, label: string): string {
264
+ const ext = /mp4/i.test(rec.mimeType) ? "mp4" : "webm";
265
+ const safeLabel = label.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "") || "clip";
266
+ return `${safeLabel}-${rec.recordingId}.${ext}`;
267
+ }
268
+
269
+ /* A set's "download all": one <a> per member, clicked in turn. Each still
270
+ downloads under its own clip filename (no zipping); good enough for an
271
+ operator who wants every feed of the moment without N separate clicks. */
272
+ function downloadAll(
273
+ members: RecordingHandle[],
274
+ urls: Map<string, string>,
275
+ labelFor: (flightId: number) => string,
276
+ ): void {
277
+ for (const member of members) {
278
+ const url = urls.get(member.recordingId);
279
+ if (!url) continue;
280
+ const a = document.createElement("a");
281
+ a.href = url;
282
+ a.download = clipFileName(member, labelFor(member.flightId));
283
+ document.body.appendChild(a);
284
+ a.click();
285
+ a.remove();
286
+ }
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // Styled components
291
+ // ---------------------------------------------------------------------------
292
+
293
+ const Tray = styled.div`
294
+ display: flex;
295
+ flex-direction: column;
296
+ gap: 8px;
297
+ min-width: 260px;
298
+ max-height: min(70vh, 520px);
299
+ overflow-y: auto;
300
+ overflow-x: hidden;
301
+ padding: 10px;
302
+ background: var(--kerbcast-tray-bg, rgba(0, 0, 0, 0.85));
303
+ border: 1px solid var(--kerbcast-tray-border, rgba(255, 255, 255, 0.3));
304
+ border-radius: 6px;
305
+ color: var(--kerbcast-tray-text, #fff);
306
+ font-size: 12px;
307
+ `;
308
+
309
+ const Empty = styled.div`
310
+ padding: 16px 8px;
311
+ text-align: center;
312
+ color: var(--kerbcast-tray-text-muted, rgba(255, 255, 255, 0.65));
313
+ font-style: italic;
314
+ `;
315
+
316
+ const Notice = styled.div`
317
+ padding: 2px 2px 4px;
318
+ color: var(--kerbcast-tray-text-muted, rgba(255, 255, 255, 0.55));
319
+ font-size: 10.5px;
320
+ font-style: italic;
321
+ letter-spacing: 0.01em;
322
+ `;
323
+
324
+ const ClipList = styled.div`
325
+ display: flex;
326
+ flex-direction: column;
327
+ gap: 8px;
328
+ `;
329
+
330
+ const Clip = styled.div`
331
+ display: flex;
332
+ flex-wrap: wrap;
333
+ align-items: flex-start;
334
+ gap: 8px;
335
+ padding: 6px 0;
336
+ border-top: 1px solid var(--kerbcast-tray-border-subtle, rgba(255, 255, 255, 0.12));
337
+
338
+ &:first-child {
339
+ border-top: none;
340
+ padding-top: 0;
341
+ }
342
+ `;
343
+
344
+ const ClipPreview = styled.video`
345
+ width: 120px;
346
+ aspect-ratio: 16 / 9;
347
+ background: #000;
348
+ border-radius: 4px;
349
+ flex-shrink: 0;
350
+ object-fit: cover;
351
+ `;
352
+
353
+ const ClipPreviewPlaceholder = styled.div`
354
+ width: 120px;
355
+ aspect-ratio: 16 / 9;
356
+ background: rgba(255, 255, 255, 0.05);
357
+ border-radius: 4px;
358
+ flex-shrink: 0;
359
+ `;
360
+
361
+ const ClipMeta = styled.div`
362
+ display: flex;
363
+ flex-direction: column;
364
+ gap: 2px;
365
+ flex: 1 1 140px;
366
+ min-width: 0;
367
+ `;
368
+
369
+ const ClipName = styled.div`
370
+ font-weight: 600;
371
+ letter-spacing: 0.03em;
372
+ overflow: hidden;
373
+ text-overflow: ellipsis;
374
+ white-space: nowrap;
375
+ `;
376
+
377
+ const ClipDetail = styled.div`
378
+ color: var(--kerbcast-tray-text-muted, rgba(255, 255, 255, 0.65));
379
+ font-size: 11px;
380
+ `;
381
+
382
+ const ClipActions = styled.div`
383
+ display: flex;
384
+ flex-direction: column;
385
+ align-items: flex-start;
386
+ gap: 4px;
387
+ flex-shrink: 0;
388
+ `;
389
+
390
+ const trayButtonBase = `
391
+ padding: 3px 8px;
392
+ border-radius: 3px;
393
+ font-size: 11px;
394
+ letter-spacing: 0.03em;
395
+ text-decoration: none;
396
+ cursor: pointer;
397
+ white-space: nowrap;
398
+ `;
399
+
400
+ const TrayButton = styled.button<{ $danger?: boolean }>`
401
+ ${trayButtonBase}
402
+ background: transparent;
403
+ border: 1px solid
404
+ ${(p) =>
405
+ p.$danger
406
+ ? "var(--kerbcast-tray-danger, #ff6b5e)"
407
+ : "var(--kerbcast-accent, #00ff88)"};
408
+ color: ${(p) => (p.$danger ? "var(--kerbcast-tray-danger, #ff6b5e)" : "var(--kerbcast-accent, #00ff88)")};
409
+
410
+ @media (hover: hover) {
411
+ &:hover {
412
+ background: ${(p) =>
413
+ p.$danger ? "rgba(255, 107, 94, 0.15)" : "var(--kerbcast-accent-wash, rgba(0, 255, 136, 0.15))"};
414
+ }
415
+ }
416
+ `;
417
+
418
+ const TrayLink = styled.a`
419
+ ${trayButtonBase}
420
+ display: inline-block;
421
+ background: transparent;
422
+ border: 1px solid var(--kerbcast-accent, #00ff88);
423
+ color: var(--kerbcast-accent, #00ff88);
424
+
425
+ @media (hover: hover) {
426
+ &:hover {
427
+ background: var(--kerbcast-accent-wash, rgba(0, 255, 136, 0.15));
428
+ }
429
+ }
430
+ `;
431
+
432
+ const GroupCard = styled.div`
433
+ display: flex;
434
+ flex-direction: column;
435
+ gap: 4px;
436
+ padding: 8px;
437
+ background: var(--kerbcast-tray-surface-raised, rgba(255, 255, 255, 0.04));
438
+ border: 1px solid var(--kerbcast-tray-border-subtle, rgba(255, 255, 255, 0.12));
439
+ border-left: 2px solid var(--kerbcast-accent, #00ff88);
440
+ border-radius: 4px;
441
+
442
+ ${Clip} {
443
+ border-top-color: var(--kerbcast-tray-border-subtle, rgba(255, 255, 255, 0.12));
444
+ }
445
+ `;
446
+
447
+ const GroupHeader = styled.div`
448
+ display: flex;
449
+ flex-wrap: wrap;
450
+ align-items: flex-start;
451
+ justify-content: space-between;
452
+ gap: 8px;
453
+ `;
454
+
455
+ const GroupHeaderText = styled.div`
456
+ display: flex;
457
+ flex-direction: column;
458
+ gap: 2px;
459
+ `;
460
+
461
+ const GroupTitle = styled.div`
462
+ font-weight: 600;
463
+ letter-spacing: 0.03em;
464
+ `;
465
+
466
+ const GroupMeta = styled.div`
467
+ color: var(--kerbcast-tray-text-muted, rgba(255, 255, 255, 0.65));
468
+ font-size: 11px;
469
+ `;
470
+
471
+ const GroupActions = styled.div`
472
+ display: flex;
473
+ gap: 6px;
474
+ flex-shrink: 0;
475
+ `;
@@ -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
+ }