@ksp-gonogo/kerbcast-react 1.6.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +19 -0
- package/dist/CameraFeed.d.ts +31 -3
- package/dist/CameraFeed.js +86 -49
- package/dist/KerbalFaceFeed.d.ts +82 -0
- package/dist/KerbalFaceFeed.js +118 -0
- package/dist/context.d.ts +27 -1
- package/dist/context.js +68 -2
- package/dist/hooks/useReportDisplaySize.d.ts +23 -0
- package/dist/hooks/useReportDisplaySize.js +71 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +176 -12
- package/src/CameraFeed.tsx +196 -56
- package/src/KerbalFaceFeed.test.tsx +203 -0
- package/src/KerbalFaceFeed.tsx +236 -0
- package/src/context.test.tsx +94 -0
- package/src/context.tsx +98 -1
- package/src/hooks/useReportDisplaySize.test.tsx +166 -0
- package/src/hooks/useReportDisplaySize.ts +91 -0
- package/src/index.ts +12 -1
package/src/CameraFeed.tsx
CHANGED
|
@@ -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):
|
|
295
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 &&
|
|
912
|
+
flightId !== null &&
|
|
913
|
+
(pipAvailable || fullscreenAvailable || qualityAvailable || trackingAvailable);
|
|
861
914
|
const hasActionBar =
|
|
862
|
-
|
|
863
|
-
(
|
|
864
|
-
|
|
865
|
-
|
|
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
|
|
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
|
▲
|
|
@@ -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
|
▼
|
|
@@ -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
|
◀
|
|
@@ -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
|
▶
|
|
@@ -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
|
+
});
|