@ksp-gonogo/kerbcast-react 1.10.2 → 1.11.0-rc.10
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/README.md +19 -4
- package/dist/CameraFeed.d.ts +21 -0
- package/dist/CameraFeed.js +172 -33
- package/dist/CommandBrackets.d.ts +110 -0
- package/dist/CommandBrackets.js +167 -0
- package/dist/KerbalFaceFeed.js +2 -2
- package/dist/hooks/useCommandDisplayUt.d.ts +22 -0
- package/dist/hooks/useCommandDisplayUt.js +104 -0
- package/dist/hooks/useDisplayedFrameUt.d.ts +11 -0
- package/dist/hooks/useDisplayedFrameUt.js +22 -0
- package/dist/hooks/useReportDisplaySize.d.ts +49 -3
- package/dist/hooks/useReportDisplaySize.js +187 -21
- package/dist/hooks/useTargetBracketsPreference.d.ts +6 -0
- package/dist/hooks/useTargetBracketsPreference.js +39 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +538 -8
- package/src/CameraFeed.tsx +520 -242
- package/src/CommandBrackets.test.tsx +259 -0
- package/src/CommandBrackets.tsx +341 -0
- package/src/KerbalFaceFeed.tsx +2 -2
- package/src/hooks/useCommandDisplayUt.test.tsx +156 -0
- package/src/hooks/useCommandDisplayUt.ts +135 -0
- package/src/hooks/useDisplayedFrameUt.ts +26 -0
- package/src/hooks/useReportDisplaySize.loop.test.tsx +290 -0
- package/src/hooks/useReportDisplaySize.test.tsx +18 -9
- package/src/hooks/useReportDisplaySize.ts +232 -23
- package/src/hooks/useTargetBracketsPreference.ts +38 -0
- package/src/index.test.ts +2 -1
- package/src/index.ts +15 -0
package/src/CameraFeed.tsx
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { CameraState, KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CameraKind,
|
|
4
|
+
CommandAimKind,
|
|
5
|
+
CommandTargetEligibility,
|
|
6
|
+
PanZoomController,
|
|
7
|
+
QualityPreset,
|
|
8
|
+
TrackMode,
|
|
9
|
+
effectiveCommandTrack,
|
|
10
|
+
formatCommandRange,
|
|
11
|
+
} from "@ksp-gonogo/kerbcast";
|
|
3
12
|
import {
|
|
4
13
|
forwardRef,
|
|
5
14
|
useCallback,
|
|
@@ -23,11 +32,14 @@ import {
|
|
|
23
32
|
type FeedAction,
|
|
24
33
|
type FeedActionBarEntry,
|
|
25
34
|
} from "./FeedActionBar";
|
|
35
|
+
import { CommandBlank, CommandBrackets, type CommandBracketOverlay } from "./CommandBrackets";
|
|
36
|
+
import { useCommandDisplayUt } from "./hooks/useCommandDisplayUt";
|
|
26
37
|
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
27
38
|
import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
28
39
|
import { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
29
40
|
import { useRecordings } from "./hooks/useRecordings";
|
|
30
|
-
import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
41
|
+
import { DEFAULT_FEED_ASPECT, useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
42
|
+
import { useTargetBracketsPreference } from "./hooks/useTargetBracketsPreference";
|
|
31
43
|
import { StandbyIcon } from "./StandbyIcon";
|
|
32
44
|
import { isCameraDestroyed } from "./lifecycle";
|
|
33
45
|
import { usePortalMenu } from "./menuPositioning";
|
|
@@ -290,6 +302,26 @@ export interface CameraFeedProps {
|
|
|
290
302
|
* {@link CameraStreamHook}.
|
|
291
303
|
*/
|
|
292
304
|
useStream?: CameraStreamHook;
|
|
305
|
+
/**
|
|
306
|
+
* Draw target brackets and their readout over command camera feeds, with a
|
|
307
|
+
* per-feed "Target brackets" toggle in the action bar (on by default,
|
|
308
|
+
* remembered per camera in this browser). Default true. A blanked command
|
|
309
|
+
* feed shows its reason whatever this is set to.
|
|
310
|
+
*/
|
|
311
|
+
enableTargetBrackets?: boolean;
|
|
312
|
+
/**
|
|
313
|
+
* Extra drawing, corner styling and readout text for this feed's target
|
|
314
|
+
* brackets. See {@link CommandBracketOverlay}.
|
|
315
|
+
*/
|
|
316
|
+
targetBracketOverlay?: CommandBracketOverlay;
|
|
317
|
+
/**
|
|
318
|
+
* Mission UT the displayed video was captured at, for a host running its
|
|
319
|
+
* own delayed playout. Picks which command-track transition is in force and
|
|
320
|
+
* ages the readout. Defaults to the displayed frame's own capture UT where
|
|
321
|
+
* the browser reports frame RTP timestamps, else the client's capture
|
|
322
|
+
* clock interpolated to now.
|
|
323
|
+
*/
|
|
324
|
+
displayUt?: number;
|
|
293
325
|
}
|
|
294
326
|
|
|
295
327
|
export interface CameraFeedHandle {
|
|
@@ -328,6 +360,9 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
328
360
|
trailingActions,
|
|
329
361
|
showActions = true,
|
|
330
362
|
useStream,
|
|
363
|
+
enableTargetBrackets = true,
|
|
364
|
+
targetBracketOverlay,
|
|
365
|
+
displayUt: displayUtProp,
|
|
331
366
|
},
|
|
332
367
|
ref,
|
|
333
368
|
) {
|
|
@@ -427,7 +462,14 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
427
462
|
// built-in hook is the default. See CameraStreamHook's rules-of-hooks note.
|
|
428
463
|
const resolveStream = useStream ?? useKerbcastStream;
|
|
429
464
|
const stream = resolveStream(flightId);
|
|
430
|
-
const videoRef = useRef<HTMLVideoElement>(null);
|
|
465
|
+
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
466
|
+
/* Mirrors videoRef as state so the bracket overlay can measure the element
|
|
467
|
+
once it mounts. Setting a ref never remounts the video. */
|
|
468
|
+
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
|
|
469
|
+
const attachVideo = useCallback((el: HTMLVideoElement | null) => {
|
|
470
|
+
videoRef.current = el;
|
|
471
|
+
setVideoEl(el);
|
|
472
|
+
}, []);
|
|
431
473
|
// The feed frame; fullscreen targets this and ResizeObserver measures it.
|
|
432
474
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
433
475
|
useEffect(() => {
|
|
@@ -533,6 +575,26 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
533
575
|
const supportsPitch =
|
|
534
576
|
!!camera && camera.panPitchMax - camera.panPitchMin > 0;
|
|
535
577
|
|
|
578
|
+
/* Command camera feeds: target brackets, readout and blank label. */
|
|
579
|
+
const commandCamera =
|
|
580
|
+
camera?.kind === CameraKind.Command && !isDestroyed ? camera : null;
|
|
581
|
+
const isCommandFeed = commandCamera !== null;
|
|
582
|
+
const commandTrack = commandCamera?.commandTrack ?? null;
|
|
583
|
+
const commandDisplayUt = useCommandDisplayUt(
|
|
584
|
+
commandTrack !== null,
|
|
585
|
+
displayUtProp,
|
|
586
|
+
commandTrack?.events,
|
|
587
|
+
commandCamera?.flightId ?? null,
|
|
588
|
+
videoEl,
|
|
589
|
+
);
|
|
590
|
+
const commandState = commandTrack
|
|
591
|
+
? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut)
|
|
592
|
+
: null;
|
|
593
|
+
const [bracketsOn, toggleBrackets] = useTargetBracketsPreference(
|
|
594
|
+
commandCamera ? (commandCamera.commandCentre?.stationId ?? String(commandCamera.flightId)) : null,
|
|
595
|
+
);
|
|
596
|
+
const targetBracketsAvailable = enableTargetBrackets && isCommandFeed;
|
|
597
|
+
|
|
536
598
|
// -------------------------------------------------------------------------
|
|
537
599
|
// Camera selection callbacks (Next/Prev, picker)
|
|
538
600
|
// onSelectCamera is ONLY called on explicit user picks, never on auto-latch.
|
|
@@ -678,10 +740,36 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
678
740
|
// -------------------------------------------------------------------------
|
|
679
741
|
// Display-size reporting (auto-resolution). The feed self-measures its
|
|
680
742
|
// rendered box and reports it per-consumer; the sidecar maxes across
|
|
681
|
-
// consumers.
|
|
682
|
-
//
|
|
743
|
+
// consumers. Auto picks the resolution, never the shape: the request and
|
|
744
|
+
// the feed's own box keep the camera's configured capture aspect (16:9
|
|
745
|
+
// when unknown). Disabled by `renderSize="none"`. The video and the
|
|
746
|
+
// sidecar's render size let it ignore a resize that only follows the
|
|
747
|
+
// stream. See useReportDisplaySize for debounce / bucketing / the guard.
|
|
683
748
|
// -------------------------------------------------------------------------
|
|
684
|
-
|
|
749
|
+
const baseW = camera?.maxWidth ?? 0;
|
|
750
|
+
const baseH = camera?.maxHeight ?? 0;
|
|
751
|
+
const hasBase = baseW > 0 && baseH > 0;
|
|
752
|
+
const feedAspect = hasBase ? baseW / baseH : DEFAULT_FEED_ASPECT;
|
|
753
|
+
const feedShape = useMemo(
|
|
754
|
+
() =>
|
|
755
|
+
({
|
|
756
|
+
"--kc-feed-w": hasBase ? baseW : 16,
|
|
757
|
+
"--kc-feed-h": hasBase ? baseH : 9,
|
|
758
|
+
}) as React.CSSProperties,
|
|
759
|
+
[hasBase, baseW, baseH],
|
|
760
|
+
);
|
|
761
|
+
const renderedW = camera?.renderWidth ?? 0;
|
|
762
|
+
const renderedH = camera?.renderHeight ?? 0;
|
|
763
|
+
const renderedSize = useMemo(
|
|
764
|
+
() => ({ width: renderedW, height: renderedH }),
|
|
765
|
+
[renderedW, renderedH],
|
|
766
|
+
);
|
|
767
|
+
useReportDisplaySize(flightId, wrapRef, {
|
|
768
|
+
enabled: renderSize === "auto",
|
|
769
|
+
aspect: feedAspect,
|
|
770
|
+
videoRef,
|
|
771
|
+
streamSize: renderedSize,
|
|
772
|
+
});
|
|
685
773
|
|
|
686
774
|
// -------------------------------------------------------------------------
|
|
687
775
|
// UI state
|
|
@@ -766,6 +854,36 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
766
854
|
[client, flightId, trackMode, closeTrackingMenu],
|
|
767
855
|
);
|
|
768
856
|
|
|
857
|
+
// -------------------------------------------------------------------------
|
|
858
|
+
// Command camera target picker. Lists every craft the game offers; the
|
|
859
|
+
// ineligible ones stay visible but disabled with the game's reason, and
|
|
860
|
+
// the current target follows the published subjectPersistentId. Picking
|
|
861
|
+
// one aims the camera at it. Hidden while a command authority holds the
|
|
862
|
+
// aim lock, since only it can aim then.
|
|
863
|
+
// -------------------------------------------------------------------------
|
|
864
|
+
const commandTargets = commandCamera?.commandCentre?.targets ?? [];
|
|
865
|
+
const commandTargetId = commandCamera?.commandCentre?.subjectPersistentId ?? 0;
|
|
866
|
+
const commandLocked = commandCamera?.commandTrack?.authority != null;
|
|
867
|
+
const commandTargetAvailable =
|
|
868
|
+
isCommandFeed && !disableManualControls && !commandLocked && commandTargets.length > 0;
|
|
869
|
+
const commandTargetMenuId = useId();
|
|
870
|
+
const commandTargetMenu = usePortalMenu({
|
|
871
|
+
maxWidth: MENU_MAX_WIDTH,
|
|
872
|
+
maxHeight: MENU_MAX_HEIGHT,
|
|
873
|
+
align: "end",
|
|
874
|
+
});
|
|
875
|
+
const closeCommandTargetMenu = commandTargetMenu.close;
|
|
876
|
+
const selectCommandTarget = useCallback(
|
|
877
|
+
(persistentId: number) => {
|
|
878
|
+
if (flightId === null) return;
|
|
879
|
+
client
|
|
880
|
+
.setCommandAim(flightId, { kind: CommandAimKind.Vessel, persistentId })
|
|
881
|
+
.catch(() => {});
|
|
882
|
+
closeCommandTargetMenu();
|
|
883
|
+
},
|
|
884
|
+
[client, flightId, closeCommandTargetMenu],
|
|
885
|
+
);
|
|
886
|
+
|
|
769
887
|
// -------------------------------------------------------------------------
|
|
770
888
|
// REC control (opt-in). A stateful toggle that starts/stops a client-side
|
|
771
889
|
// recording of this feed via the recordings store; state is read from the
|
|
@@ -899,6 +1017,13 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
899
1017
|
const pipLabel = isPip ? "Exit picture in picture" : "Picture in picture";
|
|
900
1018
|
const fullscreenLabel = isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
|
|
901
1019
|
const recordingLabel = recordingActive ? "Stop recording" : "Start recording";
|
|
1020
|
+
const bracketsLabel = "Target brackets";
|
|
1021
|
+
const commandTargetName = commandTargets.find(
|
|
1022
|
+
(t) => t.persistentId === commandTargetId,
|
|
1023
|
+
)?.vesselName;
|
|
1024
|
+
const commandTargetLabel = commandTargetName
|
|
1025
|
+
? `Watching ${commandTargetName}`
|
|
1026
|
+
: "Choose target";
|
|
902
1027
|
|
|
903
1028
|
const entries: FeedActionBarEntry[] = [
|
|
904
1029
|
...(actions?.map(feedActionToEntry) ?? []),
|
|
@@ -907,7 +1032,8 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
907
1032
|
{
|
|
908
1033
|
id: "quality",
|
|
909
1034
|
label: "Quality",
|
|
910
|
-
|
|
1035
|
+
/* Stays inline: its menu anchors on this button, which the overflow menu unmounts on click. */
|
|
1036
|
+
stateful: true,
|
|
911
1037
|
render: () => (
|
|
912
1038
|
<OverlayIconButton
|
|
913
1039
|
ref={qualityMenu.triggerRef}
|
|
@@ -952,6 +1078,52 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
952
1078
|
} satisfies FeedActionBarEntry,
|
|
953
1079
|
]
|
|
954
1080
|
: []),
|
|
1081
|
+
...(targetBracketsAvailable
|
|
1082
|
+
? [
|
|
1083
|
+
{
|
|
1084
|
+
id: "target-brackets",
|
|
1085
|
+
label: bracketsLabel,
|
|
1086
|
+
stateful: true,
|
|
1087
|
+
render: () => (
|
|
1088
|
+
<OverlayIconButton
|
|
1089
|
+
type="button"
|
|
1090
|
+
aria-label={bracketsLabel}
|
|
1091
|
+
aria-pressed={bracketsOn}
|
|
1092
|
+
title={bracketsLabel}
|
|
1093
|
+
$active={bracketsOn}
|
|
1094
|
+
onClick={toggleBrackets}
|
|
1095
|
+
>
|
|
1096
|
+
<TargetBracketsIcon />
|
|
1097
|
+
</OverlayIconButton>
|
|
1098
|
+
),
|
|
1099
|
+
} satisfies FeedActionBarEntry,
|
|
1100
|
+
]
|
|
1101
|
+
: []),
|
|
1102
|
+
...(commandTargetAvailable
|
|
1103
|
+
? [
|
|
1104
|
+
{
|
|
1105
|
+
id: "command-target",
|
|
1106
|
+
label: commandTargetLabel,
|
|
1107
|
+
/* Stays inline: its menu anchors on this button, which the overflow menu unmounts on click. */
|
|
1108
|
+
stateful: true,
|
|
1109
|
+
render: () => (
|
|
1110
|
+
<OverlayIconButton
|
|
1111
|
+
ref={commandTargetMenu.triggerRef}
|
|
1112
|
+
type="button"
|
|
1113
|
+
aria-label="Choose target"
|
|
1114
|
+
aria-haspopup="menu"
|
|
1115
|
+
aria-expanded={commandTargetMenu.open}
|
|
1116
|
+
aria-controls={commandTargetMenuId}
|
|
1117
|
+
title={commandTargetLabel}
|
|
1118
|
+
$active={commandTargetMenu.open}
|
|
1119
|
+
onClick={commandTargetMenu.toggle}
|
|
1120
|
+
>
|
|
1121
|
+
<CommandTargetIcon />
|
|
1122
|
+
</OverlayIconButton>
|
|
1123
|
+
),
|
|
1124
|
+
} satisfies FeedActionBarEntry,
|
|
1125
|
+
]
|
|
1126
|
+
: []),
|
|
955
1127
|
...(trackingAvailable
|
|
956
1128
|
? [
|
|
957
1129
|
{
|
|
@@ -1052,6 +1224,8 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1052
1224
|
fullscreenAvailable ||
|
|
1053
1225
|
qualityAvailable ||
|
|
1054
1226
|
trackingAvailable ||
|
|
1227
|
+
targetBracketsAvailable ||
|
|
1228
|
+
commandTargetAvailable ||
|
|
1055
1229
|
enableRecording);
|
|
1056
1230
|
const hasActionBar =
|
|
1057
1231
|
showActions &&
|
|
@@ -1084,254 +1258,309 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1084
1258
|
) : null;
|
|
1085
1259
|
|
|
1086
1260
|
return (
|
|
1087
|
-
<
|
|
1088
|
-
{
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
<CameraMenuItem
|
|
1119
|
-
type="button"
|
|
1120
|
-
role="menuitemradio"
|
|
1121
|
-
aria-checked={camera.viewerQuality == null}
|
|
1122
|
-
$selected={camera.viewerQuality == null}
|
|
1123
|
-
onClick={() => selectQuality(null)}
|
|
1261
|
+
<FeedBox style={feedShape}>
|
|
1262
|
+
<Stage ref={wrapRef} $pinned={chromePinned}>
|
|
1263
|
+
{flightId === null ? (
|
|
1264
|
+
<>
|
|
1265
|
+
<Empty>{emptyMessage}</Empty>
|
|
1266
|
+
{topOverlay}
|
|
1267
|
+
{actionBar}
|
|
1268
|
+
</>
|
|
1269
|
+
) : (
|
|
1270
|
+
<>
|
|
1271
|
+
<StyledVideo
|
|
1272
|
+
ref={attachVideo}
|
|
1273
|
+
autoPlay
|
|
1274
|
+
playsInline
|
|
1275
|
+
muted
|
|
1276
|
+
controls={false}
|
|
1277
|
+
onClick={() => setChromePinned((v) => !v)}
|
|
1278
|
+
/>
|
|
1279
|
+
{topOverlay}
|
|
1280
|
+
{actionBar}
|
|
1281
|
+
{qualityAvailable &&
|
|
1282
|
+
qualityMenu.open &&
|
|
1283
|
+
qualityMenu.position &&
|
|
1284
|
+
camera &&
|
|
1285
|
+
createPortal(
|
|
1286
|
+
<QualityMenu
|
|
1287
|
+
ref={qualityMenu.menuRef}
|
|
1288
|
+
id={qualityMenuId}
|
|
1289
|
+
role="menu"
|
|
1290
|
+
aria-label="Quality"
|
|
1291
|
+
style={qualityMenu.position}
|
|
1124
1292
|
>
|
|
1125
|
-
Auto
|
|
1126
|
-
</CameraMenuItem>
|
|
1127
|
-
{QUALITY_PRESETS.map(({ preset, label, scale }) => (
|
|
1128
1293
|
<CameraMenuItem
|
|
1129
|
-
key={preset}
|
|
1130
1294
|
type="button"
|
|
1131
1295
|
role="menuitemradio"
|
|
1132
|
-
aria-checked={camera.viewerQuality
|
|
1133
|
-
$selected={camera.viewerQuality
|
|
1134
|
-
onClick={() => selectQuality(
|
|
1296
|
+
aria-checked={camera.viewerQuality == null}
|
|
1297
|
+
$selected={camera.viewerQuality == null}
|
|
1298
|
+
onClick={() => selectQuality(null)}
|
|
1135
1299
|
>
|
|
1136
|
-
|
|
1137
|
-
{presetDim(camera.operatorHeight, scale)})
|
|
1300
|
+
Auto
|
|
1138
1301
|
</CameraMenuItem>
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1302
|
+
{QUALITY_PRESETS.map(({ preset, label, scale }) => (
|
|
1303
|
+
<CameraMenuItem
|
|
1304
|
+
key={preset}
|
|
1305
|
+
type="button"
|
|
1306
|
+
role="menuitemradio"
|
|
1307
|
+
aria-checked={camera.viewerQuality === preset}
|
|
1308
|
+
$selected={camera.viewerQuality === preset}
|
|
1309
|
+
onClick={() => selectQuality(preset)}
|
|
1310
|
+
>
|
|
1311
|
+
{label} ({presetDim(camera.operatorWidth, scale)}×
|
|
1312
|
+
{presetDim(camera.operatorHeight, scale)})
|
|
1313
|
+
</CameraMenuItem>
|
|
1314
|
+
))}
|
|
1315
|
+
<QualityMeta role="status">
|
|
1316
|
+
now {camera.renderWidth}×{camera.renderHeight}
|
|
1317
|
+
{qualityThrottled ? " · throttled" : ""}
|
|
1318
|
+
</QualityMeta>
|
|
1319
|
+
</QualityMenu>,
|
|
1320
|
+
document.body,
|
|
1321
|
+
)}
|
|
1322
|
+
{trackingAvailable &&
|
|
1323
|
+
trackingMenu.open &&
|
|
1324
|
+
trackingMenu.position &&
|
|
1325
|
+
camera &&
|
|
1326
|
+
createPortal(
|
|
1327
|
+
<QualityMenu
|
|
1328
|
+
ref={trackingMenu.menuRef}
|
|
1329
|
+
id={trackingMenuId}
|
|
1330
|
+
role="menu"
|
|
1331
|
+
aria-label="Auto-track"
|
|
1332
|
+
style={trackingMenu.position}
|
|
1333
|
+
>
|
|
1334
|
+
<CameraMenuItem
|
|
1335
|
+
type="button"
|
|
1336
|
+
role="menuitemradio"
|
|
1337
|
+
aria-checked={trackMode === TrackMode.ActiveVessel}
|
|
1338
|
+
$selected={trackMode === TrackMode.ActiveVessel}
|
|
1339
|
+
onClick={() => selectTrack(TrackMode.ActiveVessel)}
|
|
1340
|
+
>
|
|
1341
|
+
Track active vessel
|
|
1342
|
+
</CameraMenuItem>
|
|
1343
|
+
<CameraMenuItem
|
|
1344
|
+
type="button"
|
|
1345
|
+
role="menuitemradio"
|
|
1346
|
+
aria-checked={trackMode === TrackMode.Target}
|
|
1347
|
+
$selected={trackMode === TrackMode.Target}
|
|
1348
|
+
onClick={() => selectTrack(TrackMode.Target)}
|
|
1349
|
+
>
|
|
1350
|
+
Track target
|
|
1351
|
+
</CameraMenuItem>
|
|
1352
|
+
</QualityMenu>,
|
|
1353
|
+
document.body,
|
|
1354
|
+
)}
|
|
1355
|
+
{commandTargetAvailable &&
|
|
1356
|
+
commandTargetMenu.open &&
|
|
1357
|
+
commandTargetMenu.position &&
|
|
1358
|
+
createPortal(
|
|
1359
|
+
<CameraMenu
|
|
1360
|
+
ref={commandTargetMenu.menuRef}
|
|
1361
|
+
id={commandTargetMenuId}
|
|
1362
|
+
role="menu"
|
|
1363
|
+
aria-label="Target"
|
|
1364
|
+
style={commandTargetMenu.position}
|
|
1365
|
+
>
|
|
1366
|
+
{commandTargets.map((t) => {
|
|
1367
|
+
const eligible = t.eligibility === CommandTargetEligibility.Ok;
|
|
1368
|
+
const current = t.persistentId === commandTargetId;
|
|
1369
|
+
return (
|
|
1370
|
+
<CameraMenuItem
|
|
1371
|
+
key={t.persistentId}
|
|
1372
|
+
type="button"
|
|
1373
|
+
role="menuitemradio"
|
|
1374
|
+
aria-checked={current}
|
|
1375
|
+
$selected={current}
|
|
1376
|
+
disabled={!eligible}
|
|
1377
|
+
title={eligible ? undefined : t.eligibilityText}
|
|
1378
|
+
onClick={() => selectCommandTarget(t.persistentId)}
|
|
1379
|
+
>
|
|
1380
|
+
{t.vesselName}
|
|
1381
|
+
{t.rangeM != null ? ` · ${formatCommandRange(t.rangeM)}` : ""}
|
|
1382
|
+
{eligible ? "" : ` · ${t.eligibilityText}`}
|
|
1383
|
+
</CameraMenuItem>
|
|
1384
|
+
);
|
|
1385
|
+
})}
|
|
1386
|
+
</CameraMenu>,
|
|
1387
|
+
document.body,
|
|
1388
|
+
)}
|
|
1389
|
+
{commandState &&
|
|
1390
|
+
!outOfFlight &&
|
|
1391
|
+
(
|
|
1392
|
+
<>
|
|
1393
|
+
{commandState.blank && <CommandBlank reason={commandState.blank} />}
|
|
1394
|
+
{targetBracketsAvailable && bracketsOn && (
|
|
1395
|
+
<CommandBrackets
|
|
1396
|
+
state={commandState}
|
|
1397
|
+
displayUt={commandDisplayUt?.ut ?? commandTrack!.ut}
|
|
1398
|
+
utSource={commandDisplayUt?.source ?? null}
|
|
1399
|
+
video={videoEl}
|
|
1400
|
+
fallbackSize={
|
|
1401
|
+
camera ? { width: camera.renderWidth, height: camera.renderHeight } : null
|
|
1402
|
+
}
|
|
1403
|
+
overlay={targetBracketOverlay}
|
|
1404
|
+
/>
|
|
1405
|
+
)}
|
|
1406
|
+
</>
|
|
1407
|
+
)}
|
|
1408
|
+
{outOfFlight ? (
|
|
1409
|
+
showStandbyIcon && (
|
|
1410
|
+
<StandbyOverlay role="status" aria-label="Standby, no active flight">
|
|
1411
|
+
<StandbyIconWrap>
|
|
1412
|
+
<StandbyIcon size={40} />
|
|
1413
|
+
</StandbyIconWrap>
|
|
1414
|
+
</StandbyOverlay>
|
|
1415
|
+
)
|
|
1416
|
+
) : (
|
|
1417
|
+
isDestroyed && (
|
|
1418
|
+
<SignalLostOverlay role="status" aria-label="Signal lost">
|
|
1419
|
+
<SignalLostText $animated={effectiveShowStatic}>
|
|
1420
|
+
SIGNAL LOST
|
|
1421
|
+
</SignalLostText>
|
|
1422
|
+
</SignalLostOverlay>
|
|
1423
|
+
)
|
|
1146
1424
|
)}
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1425
|
+
{!outOfFlight && !effectiveShowStatic && isStale && !isDestroyed && (
|
|
1426
|
+
<>
|
|
1427
|
+
<StaleScrim aria-hidden="true" />
|
|
1428
|
+
<StaleBadge role="status" aria-label="Feed stale">
|
|
1429
|
+
<StaleIcon />
|
|
1430
|
+
Stale
|
|
1431
|
+
</StaleBadge>
|
|
1432
|
+
</>
|
|
1433
|
+
)}
|
|
1434
|
+
{recordingActive && armingActive && (
|
|
1435
|
+
<ArmingBadge role="status" aria-label="Arming">
|
|
1436
|
+
<ArmingDot aria-hidden="true" />
|
|
1437
|
+
ARMING
|
|
1438
|
+
</ArmingBadge>
|
|
1439
|
+
)}
|
|
1440
|
+
{recordingActive && !armingActive && (
|
|
1441
|
+
<RecBadge role="status" aria-label="Recording">
|
|
1442
|
+
<RecDot aria-hidden="true" />
|
|
1443
|
+
REC {formatElapsed(recordingElapsedMs)}
|
|
1444
|
+
</RecBadge>
|
|
1445
|
+
)}
|
|
1446
|
+
{showZoom && (
|
|
1447
|
+
<ZoomControlsWrap $disabled={manualDisabled} aria-hidden={manualDisabled}>
|
|
1448
|
+
<ZoomButton
|
|
1160
1449
|
type="button"
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1450
|
+
aria-label="Zoom in"
|
|
1451
|
+
$pos="top"
|
|
1452
|
+
disabled={manualDisabled}
|
|
1453
|
+
onPointerDown={() =>
|
|
1454
|
+
controllerRef.current?.setZoomRate(1)
|
|
1455
|
+
}
|
|
1456
|
+
onPointerUp={() => controllerRef.current?.setZoomRate(0)}
|
|
1457
|
+
onPointerLeave={() => controllerRef.current?.setZoomRate(0)}
|
|
1458
|
+
onPointerCancel={() => controllerRef.current?.setZoomRate(0)}
|
|
1459
|
+
onClick={(e) => {
|
|
1460
|
+
if (e.detail === 0) controllerRef.current?.nudgeZoom(-1);
|
|
1461
|
+
}}
|
|
1165
1462
|
>
|
|
1166
|
-
|
|
1167
|
-
</
|
|
1168
|
-
<
|
|
1463
|
+
+
|
|
1464
|
+
</ZoomButton>
|
|
1465
|
+
<FovSlider
|
|
1466
|
+
type="range"
|
|
1467
|
+
aria-label="Zoom"
|
|
1468
|
+
min={camera.fovMin}
|
|
1469
|
+
max={camera.fovMax}
|
|
1470
|
+
step={0.5}
|
|
1471
|
+
value={sliderFov}
|
|
1472
|
+
disabled={manualDisabled}
|
|
1473
|
+
onChange={(e) => {
|
|
1474
|
+
const v = Number(e.target.value);
|
|
1475
|
+
controllerRef.current?.fovSliderInput(v);
|
|
1476
|
+
}}
|
|
1477
|
+
onPointerDown={() => {
|
|
1478
|
+
controllerRef.current?.setFovSliderDragging(true);
|
|
1479
|
+
}}
|
|
1480
|
+
onPointerUp={() => {
|
|
1481
|
+
controllerRef.current?.setFovSliderDragging(false);
|
|
1482
|
+
}}
|
|
1483
|
+
onPointerCancel={() => {
|
|
1484
|
+
controllerRef.current?.setFovSliderDragging(false);
|
|
1485
|
+
}}
|
|
1486
|
+
/>
|
|
1487
|
+
<ZoomButton
|
|
1169
1488
|
type="button"
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1489
|
+
aria-label="Zoom out"
|
|
1490
|
+
$pos="bottom"
|
|
1491
|
+
disabled={manualDisabled}
|
|
1492
|
+
onPointerDown={() => controllerRef.current?.setZoomRate(-1)}
|
|
1493
|
+
onPointerUp={() => controllerRef.current?.setZoomRate(0)}
|
|
1494
|
+
onPointerLeave={() => controllerRef.current?.setZoomRate(0)}
|
|
1495
|
+
onPointerCancel={() => controllerRef.current?.setZoomRate(0)}
|
|
1496
|
+
onClick={(e) => {
|
|
1497
|
+
if (e.detail === 0) controllerRef.current?.nudgeZoom(1);
|
|
1498
|
+
}}
|
|
1174
1499
|
>
|
|
1175
|
-
|
|
1176
|
-
</
|
|
1177
|
-
</
|
|
1178
|
-
document.body,
|
|
1500
|
+
−
|
|
1501
|
+
</ZoomButton>
|
|
1502
|
+
</ZoomControlsWrap>
|
|
1179
1503
|
)}
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
</StandbyOverlay>
|
|
1187
|
-
)
|
|
1188
|
-
) : (
|
|
1189
|
-
isDestroyed && (
|
|
1190
|
-
<SignalLostOverlay role="status" aria-label="Signal lost">
|
|
1191
|
-
<SignalLostText $animated={effectiveShowStatic}>
|
|
1192
|
-
SIGNAL LOST
|
|
1193
|
-
</SignalLostText>
|
|
1194
|
-
</SignalLostOverlay>
|
|
1195
|
-
)
|
|
1196
|
-
)}
|
|
1197
|
-
{!outOfFlight && !effectiveShowStatic && isStale && !isDestroyed && (
|
|
1198
|
-
<>
|
|
1199
|
-
<StaleScrim aria-hidden="true" />
|
|
1200
|
-
<StaleBadge role="status" aria-label="Feed stale">
|
|
1201
|
-
<StaleIcon />
|
|
1202
|
-
Stale
|
|
1203
|
-
</StaleBadge>
|
|
1204
|
-
</>
|
|
1205
|
-
)}
|
|
1206
|
-
{recordingActive && armingActive && (
|
|
1207
|
-
<ArmingBadge role="status" aria-label="Arming">
|
|
1208
|
-
<ArmingDot aria-hidden="true" />
|
|
1209
|
-
ARMING
|
|
1210
|
-
</ArmingBadge>
|
|
1211
|
-
)}
|
|
1212
|
-
{recordingActive && !armingActive && (
|
|
1213
|
-
<RecBadge role="status" aria-label="Recording">
|
|
1214
|
-
<RecDot aria-hidden="true" />
|
|
1215
|
-
REC {formatElapsed(recordingElapsedMs)}
|
|
1216
|
-
</RecBadge>
|
|
1217
|
-
)}
|
|
1218
|
-
{showZoom && (
|
|
1219
|
-
<ZoomControlsWrap $disabled={manualDisabled} aria-hidden={manualDisabled}>
|
|
1220
|
-
<ZoomButton
|
|
1221
|
-
type="button"
|
|
1222
|
-
aria-label="Zoom in"
|
|
1223
|
-
$pos="top"
|
|
1224
|
-
disabled={manualDisabled}
|
|
1225
|
-
onPointerDown={() =>
|
|
1226
|
-
controllerRef.current?.setZoomRate(1)
|
|
1227
|
-
}
|
|
1228
|
-
onPointerUp={() => controllerRef.current?.setZoomRate(0)}
|
|
1229
|
-
onPointerLeave={() => controllerRef.current?.setZoomRate(0)}
|
|
1230
|
-
onPointerCancel={() => controllerRef.current?.setZoomRate(0)}
|
|
1231
|
-
onClick={(e) => {
|
|
1232
|
-
if (e.detail === 0) controllerRef.current?.nudgeZoom(-1);
|
|
1233
|
-
}}
|
|
1504
|
+
{showPan && (
|
|
1505
|
+
<PanControl
|
|
1506
|
+
role="group"
|
|
1507
|
+
aria-label="Pan camera"
|
|
1508
|
+
$disabled={manualDisabled}
|
|
1509
|
+
aria-hidden={manualDisabled}
|
|
1234
1510
|
>
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
onClick={() => controllerRef.current?.nudgePan(0, 1)}
|
|
1289
|
-
>
|
|
1290
|
-
▲
|
|
1291
|
-
</PanArrow>
|
|
1292
|
-
<PanArrow
|
|
1293
|
-
type="button"
|
|
1294
|
-
$dir="down"
|
|
1295
|
-
aria-label="Pan down"
|
|
1296
|
-
disabled={manualDisabled || !supportsPitch}
|
|
1297
|
-
onClick={() => controllerRef.current?.nudgePan(0, -1)}
|
|
1298
|
-
>
|
|
1299
|
-
▼
|
|
1300
|
-
</PanArrow>
|
|
1301
|
-
<PanArrow
|
|
1302
|
-
type="button"
|
|
1303
|
-
$dir="left"
|
|
1304
|
-
aria-label="Pan left"
|
|
1305
|
-
disabled={manualDisabled}
|
|
1306
|
-
onClick={() => controllerRef.current?.nudgePan(-1, 0)}
|
|
1307
|
-
>
|
|
1308
|
-
◀
|
|
1309
|
-
</PanArrow>
|
|
1310
|
-
<PanArrow
|
|
1311
|
-
type="button"
|
|
1312
|
-
$dir="right"
|
|
1313
|
-
aria-label="Pan right"
|
|
1314
|
-
disabled={manualDisabled}
|
|
1315
|
-
onClick={() => controllerRef.current?.nudgePan(1, 0)}
|
|
1316
|
-
>
|
|
1317
|
-
▶
|
|
1318
|
-
</PanArrow>
|
|
1319
|
-
<PanBall
|
|
1320
|
-
aria-hidden="true"
|
|
1321
|
-
title="Drag to pan"
|
|
1322
|
-
onPointerDown={manualDisabled ? undefined : handleBallDown}
|
|
1323
|
-
onPointerMove={manualDisabled ? undefined : handleBallMove}
|
|
1324
|
-
onPointerUp={manualDisabled ? undefined : handleBallUp}
|
|
1325
|
-
onPointerCancel={manualDisabled ? undefined : handleBallUp}
|
|
1326
|
-
style={{
|
|
1327
|
-
transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
|
|
1328
|
-
}}
|
|
1329
|
-
/>
|
|
1330
|
-
</PanControl>
|
|
1331
|
-
)}
|
|
1332
|
-
</>
|
|
1333
|
-
)}
|
|
1334
|
-
</Stage>
|
|
1511
|
+
<PanArrow
|
|
1512
|
+
type="button"
|
|
1513
|
+
$dir="up"
|
|
1514
|
+
aria-label="Pan up"
|
|
1515
|
+
disabled={manualDisabled || !supportsPitch}
|
|
1516
|
+
onClick={() => controllerRef.current?.nudgePan(0, 1)}
|
|
1517
|
+
>
|
|
1518
|
+
▲
|
|
1519
|
+
</PanArrow>
|
|
1520
|
+
<PanArrow
|
|
1521
|
+
type="button"
|
|
1522
|
+
$dir="down"
|
|
1523
|
+
aria-label="Pan down"
|
|
1524
|
+
disabled={manualDisabled || !supportsPitch}
|
|
1525
|
+
onClick={() => controllerRef.current?.nudgePan(0, -1)}
|
|
1526
|
+
>
|
|
1527
|
+
▼
|
|
1528
|
+
</PanArrow>
|
|
1529
|
+
<PanArrow
|
|
1530
|
+
type="button"
|
|
1531
|
+
$dir="left"
|
|
1532
|
+
aria-label="Pan left"
|
|
1533
|
+
disabled={manualDisabled}
|
|
1534
|
+
onClick={() => controllerRef.current?.nudgePan(-1, 0)}
|
|
1535
|
+
>
|
|
1536
|
+
◀
|
|
1537
|
+
</PanArrow>
|
|
1538
|
+
<PanArrow
|
|
1539
|
+
type="button"
|
|
1540
|
+
$dir="right"
|
|
1541
|
+
aria-label="Pan right"
|
|
1542
|
+
disabled={manualDisabled}
|
|
1543
|
+
onClick={() => controllerRef.current?.nudgePan(1, 0)}
|
|
1544
|
+
>
|
|
1545
|
+
▶
|
|
1546
|
+
</PanArrow>
|
|
1547
|
+
<PanBall
|
|
1548
|
+
aria-hidden="true"
|
|
1549
|
+
title="Drag to pan"
|
|
1550
|
+
onPointerDown={manualDisabled ? undefined : handleBallDown}
|
|
1551
|
+
onPointerMove={manualDisabled ? undefined : handleBallMove}
|
|
1552
|
+
onPointerUp={manualDisabled ? undefined : handleBallUp}
|
|
1553
|
+
onPointerCancel={manualDisabled ? undefined : handleBallUp}
|
|
1554
|
+
style={{
|
|
1555
|
+
transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
|
|
1556
|
+
}}
|
|
1557
|
+
/>
|
|
1558
|
+
</PanControl>
|
|
1559
|
+
)}
|
|
1560
|
+
</>
|
|
1561
|
+
)}
|
|
1562
|
+
</Stage>
|
|
1563
|
+
</FeedBox>
|
|
1335
1564
|
);
|
|
1336
1565
|
},
|
|
1337
1566
|
);
|
|
@@ -1415,6 +1644,26 @@ function QualityIcon() {
|
|
|
1415
1644
|
);
|
|
1416
1645
|
}
|
|
1417
1646
|
|
|
1647
|
+
/* Target brackets: the top-left and bottom-right corners around a centre dot. */
|
|
1648
|
+
function TargetBracketsIcon() {
|
|
1649
|
+
return (
|
|
1650
|
+
<svg {...iconProps}>
|
|
1651
|
+
<path d="M2.5 7V2.5H7M13.5 9v4.5H9" />
|
|
1652
|
+
<circle cx="8" cy="8" r="1.2" fill="currentColor" stroke="none" />
|
|
1653
|
+
</svg>
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/* Command camera target: a craft silhouette inside a selection ring. */
|
|
1658
|
+
function CommandTargetIcon() {
|
|
1659
|
+
return (
|
|
1660
|
+
<svg {...iconProps}>
|
|
1661
|
+
<circle cx="8" cy="8" r="6" />
|
|
1662
|
+
<path d="M8 4.5l1.6 4H6.4zM6.6 8.5l-.9 2.5M9.4 8.5l.9 2.5" />
|
|
1663
|
+
</svg>
|
|
1664
|
+
);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1418
1667
|
/* Auto-track: a crosshair / reticle. */
|
|
1419
1668
|
function CrosshairIcon() {
|
|
1420
1669
|
return (
|
|
@@ -1738,6 +1987,15 @@ const CameraMenuItem = styled.button<{ $selected: boolean }>`
|
|
|
1738
1987
|
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
1739
1988
|
outline-offset: -2px;
|
|
1740
1989
|
}
|
|
1990
|
+
|
|
1991
|
+
&:disabled {
|
|
1992
|
+
cursor: default;
|
|
1993
|
+
color: rgba(255, 255, 255, 0.45);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
&:disabled:not([aria-checked="true"]) {
|
|
1997
|
+
background: transparent;
|
|
1998
|
+
}
|
|
1741
1999
|
`;
|
|
1742
2000
|
|
|
1743
2001
|
const TopMeta = styled.div`
|
|
@@ -1917,7 +2175,27 @@ const Panel = styled.div`
|
|
|
1917
2175
|
overflow: hidden;
|
|
1918
2176
|
`;
|
|
1919
2177
|
|
|
2178
|
+
/*
|
|
2179
|
+
* Fills the host and centres the Stage at the camera's configured aspect
|
|
2180
|
+
* (--kc-feed-w / --kc-feed-h), so the picture fills the Stage and the host's
|
|
2181
|
+
* spare room sits outside it.
|
|
2182
|
+
*/
|
|
2183
|
+
const FeedBox = styled.div`
|
|
2184
|
+
width: 100%;
|
|
2185
|
+
height: 100%;
|
|
2186
|
+
min-width: 0;
|
|
2187
|
+
min-height: 0;
|
|
2188
|
+
container-type: size;
|
|
2189
|
+
display: flex;
|
|
2190
|
+
align-items: center;
|
|
2191
|
+
justify-content: center;
|
|
2192
|
+
`;
|
|
2193
|
+
|
|
1920
2194
|
const Stage = styled(Panel)<{ $pinned: boolean }>`
|
|
2195
|
+
width: min(100cqw, 100cqh * var(--kc-feed-w) / var(--kc-feed-h));
|
|
2196
|
+
height: auto;
|
|
2197
|
+
aspect-ratio: var(--kc-feed-w) / var(--kc-feed-h);
|
|
2198
|
+
flex: none;
|
|
1921
2199
|
padding: 0;
|
|
1922
2200
|
gap: 0;
|
|
1923
2201
|
position: relative;
|