@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.
@@ -1,5 +1,5 @@
1
- import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
2
- import { PanZoomController, QualityPreset } from "@ksp-gonogo/kerbcast";
1
+ import type { CameraState, KerbcastClient } from "@ksp-gonogo/kerbcast";
2
+ import { PanZoomController, QualityPreset, TrackMode } from "@ksp-gonogo/kerbcast";
3
3
  import {
4
4
  forwardRef,
5
5
  useCallback,
@@ -17,6 +17,7 @@ import { KerbcastProvider, useKerbcastClient } from "./context";
17
17
  import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
18
18
  import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
19
19
  import { useKerbcastStream } from "./hooks/useKerbcastStream";
20
+ import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
20
21
  import { StandbyIcon } from "./StandbyIcon";
21
22
  import { isCameraDestroyed } from "./lifecycle";
22
23
 
@@ -147,11 +148,6 @@ function usePortalMenu({ maxWidth, maxHeight, align }: MenuAnchor) {
147
148
  return { open, toggle, close, position, menuRef, triggerRef };
148
149
  }
149
150
 
150
- /** Round n to the nearest even integer, minimum 2 (H.264 chroma requirement). */
151
- function toEvenPx(n: number): number {
152
- return Math.max(2, Math.round(n / 2) * 2);
153
- }
154
-
155
151
  /*
156
152
  * Viewer quality presets, in menu order. Scales mirror the sidecar's
157
153
  * QualityPreset mapping (fractions of the camera's operator render size);
@@ -258,6 +254,13 @@ export interface CameraFeedProps {
258
254
  * buttons). Never called on auto-latch.
259
255
  */
260
256
  onSelectCamera?: (flightId: number) => void;
257
+ /**
258
+ * Restrict the cameras this feed can select / step through / auto-latch onto
259
+ * (the picker menu + stepper + fallback). Omit to consider every camera (the
260
+ * default). A host uses it to scope the selectable set — e.g. a part-grid tile
261
+ * offering only part cameras so crew cams stay in the crew bar.
262
+ */
263
+ cameraFilter?: (camera: CameraState) => boolean;
261
264
  /**
262
265
  * Called whenever the camera this feed actually displays changes — including
263
266
  * auto-latch and fallback picks, not just explicit selection. The argument
@@ -291,8 +294,12 @@ export interface CameraFeedProps {
291
294
  */
292
295
  showStandbyIcon?: boolean;
293
296
  /**
294
- * "auto" (default): ResizeObserver drives `setRenderSize` at a 16:9 crop,
295
- * debounced 500 ms. "none": no render-size feedback.
297
+ * "auto" (default): the feed self-measures its rendered pixel box and reports
298
+ * it to the sidecar via the per-consumer `reportDisplaySize` (auto-resolution;
299
+ * the sidecar maxes across consumers). "none": no reporting, for a
300
+ * fixed-resolution feed. This drives the AUTO signal, not the operator
301
+ * `setRenderSize` (which, with the quality presets, is the manual cap:
302
+ * effective = min(auto, cap)).
296
303
  */
297
304
  renderSize?: "auto" | "none";
298
305
  /** Message shown when no cameras are available. */
@@ -319,6 +326,16 @@ export interface CameraFeedProps {
319
326
  * false.
320
327
  */
321
328
  enableQualityControl?: boolean;
329
+ /**
330
+ * Show a built-in auto-track (crosshair) control, only on pan+zoom cameras.
331
+ * A tri-state toggle group (off / track active vessel / track target): the
332
+ * camera aims itself at the chosen moving vessel (and auto-zooms) with no kOS.
333
+ * State is SERVER-authoritative: the highlight reflects `CameraState.trackMode`
334
+ * published by the sidecar, never the local click, so two browsers agree.
335
+ * While tracking, the manual pan/zoom controls are disabled (the aim loop owns
336
+ * the gimbal + FoV). Default false.
337
+ */
338
+ enableTracking?: boolean;
322
339
  /**
323
340
  * Consumer-injected action buttons, rendered left of the built-in
324
341
  * fullscreen/PiP controls in the top-right action bar.
@@ -330,6 +347,13 @@ export interface CameraFeedProps {
330
347
  * button so it sits in the corner.
331
348
  */
332
349
  trailingActions?: FeedAction[];
350
+ /**
351
+ * Render the feed's action UI (the top-right action bar: camera stepper,
352
+ * custom actions, quality / fullscreen / PiP). Default true. Set false to
353
+ * suppress the whole bar (e.g. a small tile where hover controls add
354
+ * nothing). Does not affect display-size reporting.
355
+ */
356
+ showActions?: boolean;
333
357
  /**
334
358
  * Override how the displayed video stream is sourced for the resolved
335
359
  * flightId. Omit (the default) to use the built-in `useKerbcastStream` —
@@ -357,6 +381,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
357
381
  {
358
382
  flightId: requestedFlightId,
359
383
  onSelectCamera,
384
+ cameraFilter,
360
385
  onDisplayedCameraChange,
361
386
  showDebugInfo = false,
362
387
  showStatic,
@@ -367,14 +392,24 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
367
392
  enableFullscreen = false,
368
393
  enablePictureInPicture = false,
369
394
  enableQualityControl = false,
395
+ enableTracking = false,
370
396
  actions,
371
397
  trailingActions,
398
+ showActions = true,
372
399
  useStream,
373
400
  },
374
401
  ref,
375
402
  ) {
376
403
  const client = useKerbcastClient();
377
- const cameras = useKerbcastCameras();
404
+ // The selectable set: every live camera, optionally narrowed by the host's
405
+ // cameraFilter (e.g. a part-grid tile offering only part cams). Drives the
406
+ // picker menu, the stepper, and the auto-latch fallback so none can land on
407
+ // a filtered-out camera.
408
+ const allCameras = useKerbcastCameras();
409
+ const cameras = useMemo(
410
+ () => (cameraFilter ? allCameras.filter(cameraFilter) : allCameras),
411
+ [allCameras, cameraFilter],
412
+ );
378
413
  const inFlightFromClient = useKerbcastInFlight();
379
414
  const inFlight = inFlightProp ?? inFlightFromClient;
380
415
  const outOfFlight = inFlight === false;
@@ -549,6 +584,14 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
549
584
  const isDestroyed = camera ? isCameraDestroyed(camera) : false;
550
585
  const showPan = camera?.supportsPan && !isDestroyed;
551
586
  const showZoom = camera?.supportsZoom && !isDestroyed;
587
+
588
+ // Auto-track state is server-authoritative: driven purely by the published
589
+ // trackMode (absent -> none), never local click. While tracking, the aim
590
+ // loop owns the gimbal + FoV, so the manual pan/zoom controls are disabled
591
+ // (they jitter against the track and are no-ops in practice).
592
+ const trackMode = camera?.trackMode ?? TrackMode.None;
593
+ const tracking = trackMode !== TrackMode.None;
594
+ const manualDisabled = tracking;
552
595
  const supportsPitch =
553
596
  !!camera && camera.panPitchMax - camera.panPitchMin > 0;
554
597
 
@@ -695,35 +738,12 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
695
738
  );
696
739
 
697
740
  // -------------------------------------------------------------------------
698
- // Render-size feedback (ResizeObserver, 500 ms debounce, 16:9 crop)
741
+ // Display-size reporting (auto-resolution). The feed self-measures its
742
+ // rendered box and reports it per-consumer; the sidecar maxes across
743
+ // consumers. Reports the real w x h (part cams are not square). Disabled by
744
+ // `renderSize="none"`. See useReportDisplaySize for debounce / bucketing.
699
745
  // -------------------------------------------------------------------------
700
- const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
701
-
702
- useEffect(() => {
703
- if (renderSize !== "auto" || flightId === null) return;
704
- const el = wrapRef.current;
705
- if (!el) return;
706
- const cam = client.camera(flightId);
707
-
708
- const observer = new ResizeObserver((entries) => {
709
- const entry = entries[0];
710
- if (!entry) return;
711
- const { width } = entry.contentRect;
712
- if (resizeTimerRef.current !== null) clearTimeout(resizeTimerRef.current);
713
- resizeTimerRef.current = setTimeout(() => {
714
- const w = toEvenPx(width);
715
- const h = toEvenPx((width * 9) / 16);
716
- void cam.setRenderSize(w, h);
717
- }, 500);
718
- });
719
-
720
- observer.observe(el);
721
-
722
- return () => {
723
- observer.disconnect();
724
- if (resizeTimerRef.current !== null) clearTimeout(resizeTimerRef.current);
725
- };
726
- }, [client, flightId, renderSize]);
746
+ useReportDisplaySize(flightId, wrapRef, { enabled: renderSize === "auto" });
727
747
 
728
748
  // -------------------------------------------------------------------------
729
749
  // UI state
@@ -776,6 +796,38 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
776
796
  [client, flightId, closeQualityMenu],
777
797
  );
778
798
 
799
+ // -------------------------------------------------------------------------
800
+ // Auto-track control (opt-in, pan+zoom cameras only). A crosshair button +
801
+ // menu with the two modes. Sends the intent; the highlight follows the
802
+ // server-published trackMode (above), so two browsers agree.
803
+ // -------------------------------------------------------------------------
804
+ const trackingAvailable =
805
+ enableTracking &&
806
+ flightId !== null &&
807
+ camera !== null &&
808
+ camera.supportsPan === true &&
809
+ camera.supportsZoom === true &&
810
+ !isDestroyed;
811
+ const trackingMenuId = useId();
812
+ const trackingMenu = usePortalMenu({
813
+ maxWidth: QUALITY_MENU_MAX_WIDTH,
814
+ maxHeight: QUALITY_MENU_MAX_HEIGHT,
815
+ align: "end",
816
+ });
817
+ const closeTrackingMenu = trackingMenu.close;
818
+
819
+ const selectTrack = useCallback(
820
+ (mode: TrackMode) => {
821
+ if (flightId === null) return;
822
+ // Tri-state: clicking the already-active mode hands aiming back (none).
823
+ // Decision reads the server-confirmed trackMode, not a local toggle.
824
+ const next = trackMode === mode ? TrackMode.None : mode;
825
+ void client.setTrackTarget(flightId, next);
826
+ closeTrackingMenu();
827
+ },
828
+ [client, flightId, trackMode, closeTrackingMenu],
829
+ );
830
+
779
831
  const topOverlay = (
780
832
  <TopOverlay>
781
833
  <TitleRow>
@@ -857,12 +909,14 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
857
909
  );
858
910
 
859
911
  const builtInActions =
860
- flightId !== null && (pipAvailable || fullscreenAvailable || qualityAvailable);
912
+ flightId !== null &&
913
+ (pipAvailable || fullscreenAvailable || qualityAvailable || trackingAvailable);
861
914
  const hasActionBar =
862
- hasCameras ||
863
- (actions && actions.length > 0) ||
864
- (trailingActions && trailingActions.length > 0) ||
865
- builtInActions;
915
+ showActions &&
916
+ (hasCameras ||
917
+ (actions && actions.length > 0) ||
918
+ (trailingActions && trailingActions.length > 0) ||
919
+ builtInActions);
866
920
  const actionBar = hasActionBar ? (
867
921
  <ActionBar>
868
922
  {hasCameras && (
@@ -906,6 +960,28 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
906
960
  {qualityThrottled && <ThrottledDot aria-hidden="true" />}
907
961
  </OverlayIconButton>
908
962
  )}
963
+ {trackingAvailable && (
964
+ <OverlayIconButton
965
+ ref={trackingMenu.triggerRef}
966
+ type="button"
967
+ aria-label="Auto-track"
968
+ aria-haspopup="menu"
969
+ aria-expanded={trackingMenu.open}
970
+ aria-controls={trackingMenuId}
971
+ aria-pressed={tracking}
972
+ title={
973
+ tracking
974
+ ? trackMode === TrackMode.Target
975
+ ? "Auto-tracking target"
976
+ : "Auto-tracking active vessel"
977
+ : "Auto-track a vessel"
978
+ }
979
+ $active={tracking || trackingMenu.open}
980
+ onClick={trackingMenu.toggle}
981
+ >
982
+ <CrosshairIcon />
983
+ </OverlayIconButton>
984
+ )}
909
985
  {flightId !== null && pipAvailable && (
910
986
  <OverlayIconButton
911
987
  type="button"
@@ -995,6 +1071,39 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
995
1071
  </QualityMenu>,
996
1072
  document.body,
997
1073
  )}
1074
+ {trackingAvailable &&
1075
+ trackingMenu.open &&
1076
+ trackingMenu.position &&
1077
+ camera &&
1078
+ createPortal(
1079
+ <QualityMenu
1080
+ ref={trackingMenu.menuRef}
1081
+ id={trackingMenuId}
1082
+ role="menu"
1083
+ aria-label="Auto-track"
1084
+ style={trackingMenu.position}
1085
+ >
1086
+ <CameraMenuItem
1087
+ type="button"
1088
+ role="menuitemradio"
1089
+ aria-checked={trackMode === TrackMode.ActiveVessel}
1090
+ $selected={trackMode === TrackMode.ActiveVessel}
1091
+ onClick={() => selectTrack(TrackMode.ActiveVessel)}
1092
+ >
1093
+ Track active vessel
1094
+ </CameraMenuItem>
1095
+ <CameraMenuItem
1096
+ type="button"
1097
+ role="menuitemradio"
1098
+ aria-checked={trackMode === TrackMode.Target}
1099
+ $selected={trackMode === TrackMode.Target}
1100
+ onClick={() => selectTrack(TrackMode.Target)}
1101
+ >
1102
+ Track target
1103
+ </CameraMenuItem>
1104
+ </QualityMenu>,
1105
+ document.body,
1106
+ )}
998
1107
  {outOfFlight ? (
999
1108
  showStandbyIcon && (
1000
1109
  <StandbyOverlay role="status" aria-label="Standby, no active flight">
@@ -1022,11 +1131,12 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1022
1131
  </>
1023
1132
  )}
1024
1133
  {showZoom && (
1025
- <ZoomControlsWrap>
1134
+ <ZoomControlsWrap $disabled={manualDisabled} aria-hidden={manualDisabled}>
1026
1135
  <ZoomButton
1027
1136
  type="button"
1028
1137
  aria-label="Zoom in"
1029
1138
  $pos="top"
1139
+ disabled={manualDisabled}
1030
1140
  onPointerDown={() =>
1031
1141
  controllerRef.current?.setZoomRate(1)
1032
1142
  }
@@ -1046,6 +1156,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1046
1156
  max={camera.fovMax}
1047
1157
  step={0.5}
1048
1158
  value={sliderFov}
1159
+ disabled={manualDisabled}
1049
1160
  onChange={(e) => {
1050
1161
  const v = Number(e.target.value);
1051
1162
  controllerRef.current?.fovSliderInput(v);
@@ -1064,6 +1175,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1064
1175
  type="button"
1065
1176
  aria-label="Zoom out"
1066
1177
  $pos="bottom"
1178
+ disabled={manualDisabled}
1067
1179
  onPointerDown={() => controllerRef.current?.setZoomRate(-1)}
1068
1180
  onPointerUp={() => controllerRef.current?.setZoomRate(0)}
1069
1181
  onPointerLeave={() => controllerRef.current?.setZoomRate(0)}
@@ -1077,12 +1189,17 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1077
1189
  </ZoomControlsWrap>
1078
1190
  )}
1079
1191
  {showPan && (
1080
- <PanControl role="group" aria-label="Pan camera">
1192
+ <PanControl
1193
+ role="group"
1194
+ aria-label="Pan camera"
1195
+ $disabled={manualDisabled}
1196
+ aria-hidden={manualDisabled}
1197
+ >
1081
1198
  <PanArrow
1082
1199
  type="button"
1083
1200
  $dir="up"
1084
1201
  aria-label="Pan up"
1085
- disabled={!supportsPitch}
1202
+ disabled={manualDisabled || !supportsPitch}
1086
1203
  onClick={() => controllerRef.current?.nudgePan(0, 1)}
1087
1204
  >
1088
1205
  &#9650;
@@ -1091,7 +1208,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1091
1208
  type="button"
1092
1209
  $dir="down"
1093
1210
  aria-label="Pan down"
1094
- disabled={!supportsPitch}
1211
+ disabled={manualDisabled || !supportsPitch}
1095
1212
  onClick={() => controllerRef.current?.nudgePan(0, -1)}
1096
1213
  >
1097
1214
  &#9660;
@@ -1100,6 +1217,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1100
1217
  type="button"
1101
1218
  $dir="left"
1102
1219
  aria-label="Pan left"
1220
+ disabled={manualDisabled}
1103
1221
  onClick={() => controllerRef.current?.nudgePan(-1, 0)}
1104
1222
  >
1105
1223
  &#9664;
@@ -1108,6 +1226,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1108
1226
  type="button"
1109
1227
  $dir="right"
1110
1228
  aria-label="Pan right"
1229
+ disabled={manualDisabled}
1111
1230
  onClick={() => controllerRef.current?.nudgePan(1, 0)}
1112
1231
  >
1113
1232
  &#9654;
@@ -1115,10 +1234,10 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1115
1234
  <PanBall
1116
1235
  aria-hidden="true"
1117
1236
  title="Drag to pan"
1118
- onPointerDown={handleBallDown}
1119
- onPointerMove={handleBallMove}
1120
- onPointerUp={handleBallUp}
1121
- onPointerCancel={handleBallUp}
1237
+ onPointerDown={manualDisabled ? undefined : handleBallDown}
1238
+ onPointerMove={manualDisabled ? undefined : handleBallMove}
1239
+ onPointerUp={manualDisabled ? undefined : handleBallUp}
1240
+ onPointerCancel={manualDisabled ? undefined : handleBallUp}
1122
1241
  style={{
1123
1242
  transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
1124
1243
  }}
@@ -1211,6 +1330,16 @@ function QualityIcon() {
1211
1330
  );
1212
1331
  }
1213
1332
 
1333
+ /* Auto-track: a crosshair / reticle. */
1334
+ function CrosshairIcon() {
1335
+ return (
1336
+ <svg {...iconProps}>
1337
+ <circle cx="8" cy="8" r="4.5" />
1338
+ <path d="M8 1v2.5M8 12.5V15M1 8h2.5M12.5 8H15" />
1339
+ </svg>
1340
+ );
1341
+ }
1342
+
1214
1343
  /* Stale badge: fading signal bars (tallest dimmed). */
1215
1344
  function StaleIcon() {
1216
1345
  return (
@@ -1242,7 +1371,7 @@ function PictureInPictureIcon() {
1242
1371
  // Styled components
1243
1372
  // ---------------------------------------------------------------------------
1244
1373
 
1245
- const PanControl = styled.div`
1374
+ const PanControl = styled.div<{ $disabled?: boolean }>`
1246
1375
  position: absolute;
1247
1376
  bottom: 10px;
1248
1377
  right: 10px;
@@ -1252,6 +1381,9 @@ const PanControl = styled.div`
1252
1381
  transition: opacity 0.15s;
1253
1382
  touch-action: none;
1254
1383
 
1384
+ /* Greyed + inert while auto-tracking owns the gimbal. */
1385
+ ${(p) => p.$disabled && "pointer-events: none; filter: grayscale(1) opacity(0.4);"}
1386
+
1255
1387
  @media (prefers-reduced-motion: reduce) {
1256
1388
  transition: none;
1257
1389
  }
@@ -1322,7 +1454,7 @@ const PanBall = styled.div`
1322
1454
  }
1323
1455
  `;
1324
1456
 
1325
- const ZoomControlsWrap = styled.div`
1457
+ const ZoomControlsWrap = styled.div<{ $disabled?: boolean }>`
1326
1458
  position: absolute;
1327
1459
  bottom: 8px;
1328
1460
  left: 8px;
@@ -1335,6 +1467,9 @@ const ZoomControlsWrap = styled.div`
1335
1467
  opacity: 0;
1336
1468
  transition: opacity 0.15s;
1337
1469
 
1470
+ /* Greyed + inert while auto-tracking owns the FoV. */
1471
+ ${(p) => p.$disabled && "pointer-events: none; filter: grayscale(1) opacity(0.4);"}
1472
+
1338
1473
  @media (prefers-reduced-motion: reduce) {
1339
1474
  transition: none;
1340
1475
  }
@@ -1654,14 +1789,19 @@ const OverlayIconButton = styled.button<{ $active?: boolean }>`
1654
1789
  align-items: center;
1655
1790
  justify-content: center;
1656
1791
  padding: 0;
1792
+ /* Active-toggle fill (quality / tracking / PiP / fullscreen / custom actions
1793
+ all share this button). Its own token so a consumer can recolour just the
1794
+ action-row highlight without touching the general accent; defaults to
1795
+ --kerbcast-accent, so the sidecar web page (which maps its accent onto that)
1796
+ is unchanged. */
1657
1797
  background: ${(p) =>
1658
1798
  p.$active
1659
- ? "var(--kerbcast-accent, #00ff88)"
1799
+ ? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
1660
1800
  : "rgba(0, 0, 0, 0.5)"};
1661
1801
  border: 1px solid
1662
1802
  ${(p) =>
1663
1803
  p.$active
1664
- ? "var(--kerbcast-accent, #00ff88)"
1804
+ ? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
1665
1805
  : "rgba(255, 255, 255, 0.3)"};
1666
1806
  border-radius: 3px;
1667
1807
  color: ${(p) => (p.$active ? "#000" : "#fff")};
@@ -1672,7 +1812,7 @@ const OverlayIconButton = styled.button<{ $active?: boolean }>`
1672
1812
  &:hover {
1673
1813
  background: ${(p) =>
1674
1814
  p.$active
1675
- ? "var(--kerbcast-accent, #00ff88)"
1815
+ ? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
1676
1816
  : "rgba(0, 0, 0, 0.7)"};
1677
1817
  border-color: rgba(255, 255, 255, 0.6);
1678
1818
  }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Tests for the `KerbalFaceFeed` primitive. Drives the real `KerbcastClient`
3
+ * through the SDK's canonical `MockSidecar` (only the WebRTC transport is
4
+ * faked — jsdom can't produce a real `MediaStream`, so a mounted feed sits in
5
+ * the no-signal state, which is exactly what the standby/never-remount
6
+ * assertions need).
7
+ */
8
+
9
+ import { KerbcastClient } from "@ksp-gonogo/kerbcast";
10
+ import { MockSidecar } from "@ksp-gonogo/kerbcast/testing";
11
+ import { act, cleanup, render } from "@testing-library/react";
12
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
13
+
14
+ import { KerbalFaceFeed, type KerbalFeedState } from "./KerbalFaceFeed";
15
+ import { KerbcastProvider, type KerbcastDisplaySizes } from "./context";
16
+
17
+ const KERBAL_ID = 0x8000_002a; // a kerbal wire-id (top bit set)
18
+
19
+ async function buildConnected(): Promise<KerbcastClient> {
20
+ const sidecar = new MockSidecar();
21
+ sidecar.addCamera({ flightId: KERBAL_ID, cameraName: "Jebediah Kerman" });
22
+ const client = new KerbcastClient(
23
+ { host: "h", port: 1, negotiate: (o) => sidecar.negotiate(o) },
24
+ sidecar.createTransport(),
25
+ );
26
+ await act(async () => {
27
+ await client.connect([], { slots: 4 });
28
+ });
29
+ await act(async () => {
30
+ sidecar.open();
31
+ sidecar.setConnectionState("connected");
32
+ });
33
+ return client;
34
+ }
35
+
36
+ afterEach(() => {
37
+ cleanup();
38
+ });
39
+
40
+ describe("KerbalFaceFeed", () => {
41
+ it("renders the square frame with a persistent video element", async () => {
42
+ const client = await buildConnected();
43
+ const { getByTestId, container } = render(
44
+ <KerbcastProvider client={client}>
45
+ <KerbalFaceFeed flightId={KERBAL_ID} size={96} />
46
+ </KerbcastProvider>,
47
+ );
48
+ // The square frame primitive is present...
49
+ expect(getByTestId("kerbal-face-feed")).toBeTruthy();
50
+ // ...and it owns exactly one <video> for the face stream.
51
+ expect(container.querySelectorAll("video")).toHaveLength(1);
52
+ });
53
+
54
+ it("shows the standby glyph and reports no-signal when there is no stream", async () => {
55
+ const client = await buildConnected();
56
+ const states: KerbalFeedState[] = [];
57
+ const { getByTestId, container } = render(
58
+ <KerbcastProvider client={client}>
59
+ <KerbalFaceFeed
60
+ flightId={KERBAL_ID}
61
+ size={96}
62
+ onFeedStateChange={(s) => states.push(s)}
63
+ />
64
+ </KerbcastProvider>,
65
+ );
66
+ // jsdom never yields a MediaStream, so the feed is out of signal.
67
+ expect(getByTestId("kerbal-face-feed").getAttribute("data-feed-state")).toBe(
68
+ "no-signal",
69
+ );
70
+ // The minimal standby glyph (an aria-hidden svg) is shown.
71
+ expect(container.querySelector("svg[aria-hidden='true']")).toBeTruthy();
72
+ // The state signal fired for the wrapper.
73
+ expect(states).toContain("no-signal");
74
+ });
75
+
76
+ it("does not draw the standby glyph when showStandby is false", async () => {
77
+ const client = await buildConnected();
78
+ const { container } = render(
79
+ <KerbcastProvider client={client}>
80
+ <KerbalFaceFeed flightId={KERBAL_ID} size={96} showStandby={false} />
81
+ </KerbcastProvider>,
82
+ );
83
+ expect(container.querySelector("svg[aria-hidden='true']")).toBeNull();
84
+ // The video element is still mounted (never conditionally swapped out).
85
+ expect(container.querySelectorAll("video")).toHaveLength(1);
86
+ });
87
+
88
+ it("never remounts the live video element across a prop change", async () => {
89
+ const client = await buildConnected();
90
+ const { container, rerender } = render(
91
+ <KerbcastProvider client={client}>
92
+ <KerbalFaceFeed flightId={KERBAL_ID} size={96} />
93
+ </KerbcastProvider>,
94
+ );
95
+ const before = container.querySelector("video");
96
+ expect(before).toBeTruthy();
97
+
98
+ // A re-layout (size change) must NOT remount the <video> — same DOM node.
99
+ rerender(
100
+ <KerbcastProvider client={client}>
101
+ <KerbalFaceFeed flightId={KERBAL_ID} size={220} />
102
+ </KerbcastProvider>,
103
+ );
104
+ const after = container.querySelector("video");
105
+ expect(after).toBe(before);
106
+ });
107
+ });
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Display-size reporting + showActions
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function recordingRegistry() {
114
+ const reports: Array<{ flightId: number; w: number; h: number }> = [];
115
+ const clears: Array<{ flightId: number }> = [];
116
+ const displaySizes: KerbcastDisplaySizes = {
117
+ report: (flightId, _id, w, h) => reports.push({ flightId, w, h }),
118
+ clear: (flightId) => clears.push({ flightId }),
119
+ };
120
+ return { displaySizes, reports, clears };
121
+ }
122
+
123
+ describe("KerbalFaceFeed - reporting + showActions", () => {
124
+ let resizeCallback:
125
+ | ((entries: ResizeObserverEntry[], observer: ResizeObserver) => void)
126
+ | undefined;
127
+ const originalResizeObserver = globalThis.ResizeObserver;
128
+
129
+ beforeEach(() => {
130
+ globalThis.ResizeObserver = class {
131
+ constructor(cb: (entries: ResizeObserverEntry[], observer: ResizeObserver) => void) {
132
+ resizeCallback = cb;
133
+ }
134
+ observe() {}
135
+ unobserve() {}
136
+ disconnect() {}
137
+ } as unknown as typeof ResizeObserver;
138
+ });
139
+
140
+ afterEach(() => {
141
+ cleanup();
142
+ globalThis.ResizeObserver = originalResizeObserver;
143
+ resizeCallback = undefined;
144
+ });
145
+
146
+ it("reports a SQUARE self-measured display size for the face", async () => {
147
+ const client = await buildConnected();
148
+ const { displaySizes, reports } = recordingRegistry();
149
+ render(
150
+ <KerbcastProvider client={client} displaySizes={displaySizes}>
151
+ <KerbalFaceFeed flightId={KERBAL_ID} />
152
+ </KerbcastProvider>,
153
+ );
154
+ // A non-square measured box still reports square (min edge, bucketed): the
155
+ // face capture is square, so 50x40 -> min 40 -> 48x48.
156
+ act(() => {
157
+ resizeCallback?.(
158
+ [{ contentRect: { width: 50, height: 40 } }] as ResizeObserverEntry[],
159
+ {} as ResizeObserver,
160
+ );
161
+ });
162
+ expect(reports.at(-1)).toEqual({ flightId: KERBAL_ID, w: 48, h: 48 });
163
+ });
164
+
165
+ it("does not report when reportSize is false", async () => {
166
+ const client = await buildConnected();
167
+ const { displaySizes, reports } = recordingRegistry();
168
+ render(
169
+ <KerbcastProvider client={client} displaySizes={displaySizes}>
170
+ <KerbalFaceFeed flightId={KERBAL_ID} reportSize={false} />
171
+ </KerbcastProvider>,
172
+ );
173
+ expect(resizeCallback).toBeUndefined();
174
+ expect(reports).toEqual([]);
175
+ });
176
+
177
+ it("renders supplied actions by default", async () => {
178
+ const client = await buildConnected();
179
+ const { getByLabelText } = render(
180
+ <KerbcastProvider client={client}>
181
+ <KerbalFaceFeed
182
+ flightId={KERBAL_ID}
183
+ actions={[{ id: "s", label: "Spotlight", icon: null, onClick: () => {} }]}
184
+ />
185
+ </KerbcastProvider>,
186
+ );
187
+ expect(getByLabelText("Spotlight")).toBeTruthy();
188
+ });
189
+
190
+ it("hides the action bar when showActions is false", async () => {
191
+ const client = await buildConnected();
192
+ const { queryByLabelText } = render(
193
+ <KerbcastProvider client={client}>
194
+ <KerbalFaceFeed
195
+ flightId={KERBAL_ID}
196
+ showActions={false}
197
+ actions={[{ id: "s", label: "Spotlight", icon: null, onClick: () => {} }]}
198
+ />
199
+ </KerbcastProvider>,
200
+ );
201
+ expect(queryByLabelText("Spotlight")).toBeNull();
202
+ });
203
+ });