@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.
@@ -1,5 +1,14 @@
1
1
  import type { CameraState, KerbcastClient } from "@ksp-gonogo/kerbcast";
2
- import { PanZoomController, QualityPreset, TrackMode } from "@ksp-gonogo/kerbcast";
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. Reports the real w x h (part cams are not square). Disabled by
682
- // `renderSize="none"`. See useReportDisplaySize for debounce / bucketing.
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
- useReportDisplaySize(flightId, wrapRef, { enabled: renderSize === "auto" });
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
- stateful: false,
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
- <Stage ref={wrapRef} $pinned={chromePinned}>
1088
- {flightId === null ? (
1089
- <>
1090
- <Empty>{emptyMessage}</Empty>
1091
- {topOverlay}
1092
- {actionBar}
1093
- </>
1094
- ) : (
1095
- <>
1096
- <StyledVideo
1097
- ref={videoRef}
1098
- autoPlay
1099
- playsInline
1100
- muted
1101
- controls={false}
1102
- onClick={() => setChromePinned((v) => !v)}
1103
- />
1104
- {topOverlay}
1105
- {actionBar}
1106
- {qualityAvailable &&
1107
- qualityMenu.open &&
1108
- qualityMenu.position &&
1109
- camera &&
1110
- createPortal(
1111
- <QualityMenu
1112
- ref={qualityMenu.menuRef}
1113
- id={qualityMenuId}
1114
- role="menu"
1115
- aria-label="Quality"
1116
- style={qualityMenu.position}
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 === preset}
1133
- $selected={camera.viewerQuality === preset}
1134
- onClick={() => selectQuality(preset)}
1296
+ aria-checked={camera.viewerQuality == null}
1297
+ $selected={camera.viewerQuality == null}
1298
+ onClick={() => selectQuality(null)}
1135
1299
  >
1136
- {label} ({presetDim(camera.operatorWidth, scale)}×
1137
- {presetDim(camera.operatorHeight, scale)})
1300
+ Auto
1138
1301
  </CameraMenuItem>
1139
- ))}
1140
- <QualityMeta role="status">
1141
- now {camera.renderWidth}×{camera.renderHeight}
1142
- {qualityThrottled ? " · throttled" : ""}
1143
- </QualityMeta>
1144
- </QualityMenu>,
1145
- document.body,
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
- {trackingAvailable &&
1148
- trackingMenu.open &&
1149
- trackingMenu.position &&
1150
- camera &&
1151
- createPortal(
1152
- <QualityMenu
1153
- ref={trackingMenu.menuRef}
1154
- id={trackingMenuId}
1155
- role="menu"
1156
- aria-label="Auto-track"
1157
- style={trackingMenu.position}
1158
- >
1159
- <CameraMenuItem
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
- role="menuitemradio"
1162
- aria-checked={trackMode === TrackMode.ActiveVessel}
1163
- $selected={trackMode === TrackMode.ActiveVessel}
1164
- onClick={() => selectTrack(TrackMode.ActiveVessel)}
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
- Track active vessel
1167
- </CameraMenuItem>
1168
- <CameraMenuItem
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
- role="menuitemradio"
1171
- aria-checked={trackMode === TrackMode.Target}
1172
- $selected={trackMode === TrackMode.Target}
1173
- onClick={() => selectTrack(TrackMode.Target)}
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
- Track target
1176
- </CameraMenuItem>
1177
- </QualityMenu>,
1178
- document.body,
1500
+ &#8722;
1501
+ </ZoomButton>
1502
+ </ZoomControlsWrap>
1179
1503
  )}
1180
- {outOfFlight ? (
1181
- showStandbyIcon && (
1182
- <StandbyOverlay role="status" aria-label="Standby, no active flight">
1183
- <StandbyIconWrap>
1184
- <StandbyIcon size={40} />
1185
- </StandbyIconWrap>
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
- </ZoomButton>
1237
- <FovSlider
1238
- type="range"
1239
- aria-label="Zoom"
1240
- min={camera.fovMin}
1241
- max={camera.fovMax}
1242
- step={0.5}
1243
- value={sliderFov}
1244
- disabled={manualDisabled}
1245
- onChange={(e) => {
1246
- const v = Number(e.target.value);
1247
- controllerRef.current?.fovSliderInput(v);
1248
- }}
1249
- onPointerDown={() => {
1250
- controllerRef.current?.setFovSliderDragging(true);
1251
- }}
1252
- onPointerUp={() => {
1253
- controllerRef.current?.setFovSliderDragging(false);
1254
- }}
1255
- onPointerCancel={() => {
1256
- controllerRef.current?.setFovSliderDragging(false);
1257
- }}
1258
- />
1259
- <ZoomButton
1260
- type="button"
1261
- aria-label="Zoom out"
1262
- $pos="bottom"
1263
- disabled={manualDisabled}
1264
- onPointerDown={() => controllerRef.current?.setZoomRate(-1)}
1265
- onPointerUp={() => controllerRef.current?.setZoomRate(0)}
1266
- onPointerLeave={() => controllerRef.current?.setZoomRate(0)}
1267
- onPointerCancel={() => controllerRef.current?.setZoomRate(0)}
1268
- onClick={(e) => {
1269
- if (e.detail === 0) controllerRef.current?.nudgeZoom(1);
1270
- }}
1271
- >
1272
- &#8722;
1273
- </ZoomButton>
1274
- </ZoomControlsWrap>
1275
- )}
1276
- {showPan && (
1277
- <PanControl
1278
- role="group"
1279
- aria-label="Pan camera"
1280
- $disabled={manualDisabled}
1281
- aria-hidden={manualDisabled}
1282
- >
1283
- <PanArrow
1284
- type="button"
1285
- $dir="up"
1286
- aria-label="Pan up"
1287
- disabled={manualDisabled || !supportsPitch}
1288
- onClick={() => controllerRef.current?.nudgePan(0, 1)}
1289
- >
1290
- &#9650;
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
- &#9660;
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
- &#9664;
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
- &#9654;
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
+ &#9650;
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
+ &#9660;
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
+ &#9664;
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
+ &#9654;
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;