@ksp-gonogo/kerbcast-react 1.11.1 → 1.12.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +71 -0
  2. package/dist/AnchorMarkers.d.ts +128 -0
  3. package/dist/AnchorMarkers.js +162 -0
  4. package/dist/CameraFeed.d.ts +45 -4
  5. package/dist/CameraFeed.js +163 -23
  6. package/dist/CommandBrackets.d.ts +61 -12
  7. package/dist/CommandBrackets.js +106 -53
  8. package/dist/FeedActionBar.d.ts +7 -0
  9. package/dist/FeedActionBar.js +4 -4
  10. package/dist/KerbalFaceFeed.d.ts +1 -1
  11. package/dist/KerbalFaceFeed.js +1 -1
  12. package/dist/RecordingsTray.d.ts +6 -0
  13. package/dist/RecordingsTray.js +17 -1
  14. package/dist/hooks/useCommandDisplayUt.d.ts +16 -3
  15. package/dist/hooks/useCommandDisplayUt.js +57 -16
  16. package/dist/hooks/useKerbcastWaitingForSlot.d.ts +5 -0
  17. package/dist/hooks/useKerbcastWaitingForSlot.js +20 -0
  18. package/dist/hooks/useTargetBracketsPreference.d.ts +3 -2
  19. package/dist/hooks/useTargetBracketsPreference.js +18 -5
  20. package/dist/index.d.ts +4 -1
  21. package/dist/index.js +5 -1
  22. package/dist/menuPositioning.d.ts +8 -2
  23. package/dist/menuPositioning.js +30 -5
  24. package/dist/sharedAnchors.d.ts +3 -0
  25. package/dist/sharedAnchors.js +61 -0
  26. package/dist/videoBox.d.ts +10 -0
  27. package/dist/videoBox.js +40 -0
  28. package/package.json +2 -2
  29. package/src/AnchorMarkers.test.tsx +173 -0
  30. package/src/AnchorMarkers.tsx +341 -0
  31. package/src/CameraFeed.test.tsx +687 -6
  32. package/src/CameraFeed.tsx +240 -27
  33. package/src/CommandBrackets.test.tsx +204 -4
  34. package/src/CommandBrackets.tsx +170 -53
  35. package/src/FeedActionBar.test.ts +22 -0
  36. package/src/FeedActionBar.tsx +11 -4
  37. package/src/KerbalFaceFeed.tsx +1 -1
  38. package/src/RecordingsTray.test.tsx +15 -0
  39. package/src/RecordingsTray.tsx +25 -1
  40. package/src/hooks/useCommandDisplayUt.test.tsx +74 -3
  41. package/src/hooks/useCommandDisplayUt.ts +61 -15
  42. package/src/hooks/useKerbcastWaitingForSlot.ts +25 -0
  43. package/src/hooks/useRecordings.sharedState.test.tsx +6 -3
  44. package/src/hooks/useRecordings.test.tsx +6 -3
  45. package/src/hooks/useTargetBracketsPreference.ts +15 -5
  46. package/src/index.ts +23 -1
  47. package/src/menuPositioning.ts +31 -5
  48. package/src/sharedAnchors.ts +67 -0
  49. package/src/videoBox.ts +49 -0
@@ -6,17 +6,20 @@ import styled, { css } from "styled-components";
6
6
  import { buildCameraLabeler } from "./cameraLabels";
7
7
  import { KerbcastProvider, useKerbcastClient } from "./context";
8
8
  import { ActionBarRow, FeedActionBar, OverlayIconButton, feedActionToEntry, } from "./FeedActionBar";
9
- import { CommandBlank, CommandBrackets } from "./CommandBrackets";
9
+ import { CommandBlank, LiveCommandBrackets } from "./CommandBrackets";
10
+ import { LiveAnchorMarkers } from "./AnchorMarkers";
10
11
  import { useCommandDisplayUt } from "./hooks/useCommandDisplayUt";
11
12
  import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
12
13
  import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
13
14
  import { useKerbcastStream } from "./hooks/useKerbcastStream";
15
+ import { useKerbcastWaitingForSlot } from "./hooks/useKerbcastWaitingForSlot";
14
16
  import { useRecordings } from "./hooks/useRecordings";
15
17
  import { DEFAULT_FEED_ASPECT, useReportDisplaySize } from "./hooks/useReportDisplaySize";
16
18
  import { useTargetBracketsPreference } from "./hooks/useTargetBracketsPreference";
17
19
  import { StandbyIcon } from "./StandbyIcon";
18
20
  import { isCameraDestroyed } from "./lifecycle";
19
- import { usePortalMenu } from "./menuPositioning";
21
+ import { claimAnchors } from "./sharedAnchors";
22
+ import { menuPortalRoot, usePortalMenu } from "./menuPositioning";
20
23
  import { formatElapsed, nowMs } from "./timing";
21
24
  // ---------------------------------------------------------------------------
22
25
  // Tuning constants
@@ -41,6 +44,15 @@ function presetDim(operatorDim, scale) {
41
44
  const v = Math.trunc(operatorDim * scale) & ~1;
42
45
  return v < 2 ? 2 : v;
43
46
  }
47
+ function videoTrackId(source) {
48
+ if (!source || !("getVideoTracks" in source))
49
+ return undefined;
50
+ return source.getVideoTracks()[0]?.id;
51
+ }
52
+ function sameVideoTrack(a, b) {
53
+ const id = videoTrackId(a);
54
+ return id !== undefined && id === videoTrackId(b);
55
+ }
44
56
  function isFullscreenSupported() {
45
57
  if (typeof document === "undefined")
46
58
  return false;
@@ -62,7 +74,7 @@ function exitFullscreen() {
62
74
  // ---------------------------------------------------------------------------
63
75
  // Inner component (reads from context)
64
76
  // ---------------------------------------------------------------------------
65
- const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, cameraFilter, onDisplayedCameraChange, showDebugInfo = false, showStatic, inFlight: inFlightProp, showStandbyIcon = true, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, enableTracking = false, disableManualControls = false, enableRecording = false, recordFullResolution = false, actions, trailingActions, showActions = true, useStream, enableTargetBrackets = true, targetBracketOverlay, displayUt: displayUtProp, }, ref) {
77
+ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, cameraFilter, onDisplayedCameraChange, showDebugInfo = false, showStatic, inFlight: inFlightProp, showStandbyIcon = true, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, enableTracking = false, disableManualControls = false, enableRecording = false, recordFullResolution = false, actions, trailingActions, showActions = true, useStream, enableTargetBrackets = true, targetBracketOverlay, waitingForTargetLabel = "Waiting for target", noSlotLabel = "Not streaming: too many feeds are open. Close a feed to watch this one.", displayUt: displayUtProp, displayFrame, anchors, anchorOverlay, }, ref) {
66
78
  const client = useKerbcastClient();
67
79
  // Always called (rules of hooks); `enableRecording` only gates whether the
68
80
  // REC entry/badge render, not whether the store is subscribed.
@@ -96,7 +108,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
96
108
  }, [showStatic]);
97
109
  const effectiveShowStatic = showStatic === undefined ? !reducedMotion : showStatic;
98
110
  // -------------------------------------------------------------------------
99
- // Selection model (mirrors gonogo's CameraFeed)
111
+ // Selection model
100
112
  //
101
113
  // Resolution order:
102
114
  // 1. Explicit pick, if still present in the list (destroyed or not).
@@ -158,10 +170,17 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
158
170
  }, []);
159
171
  // The feed frame; fullscreen targets this and ResizeObserver measures it.
160
172
  const wrapRef = useRef(null);
173
+ /* Clearing to null on a switch to a camera with no stream yet stops the
174
+ previous camera's last frame lingering. A rebind can hand over a new
175
+ MediaStream around the same track; reassigning that would reset the
176
+ element and flash black, so it is skipped. */
161
177
  useEffect(() => {
162
- if (videoRef.current && stream) {
163
- videoRef.current.srcObject = stream;
164
- }
178
+ const video = videoRef.current;
179
+ if (!video || video.srcObject === stream)
180
+ return;
181
+ if (sameVideoTrack(video.srcObject, stream))
182
+ return;
183
+ video.srcObject = stream;
165
184
  }, [stream]);
166
185
  // -------------------------------------------------------------------------
167
186
  // Stall presentation. The static is composited in-stream by the SDK's
@@ -183,6 +202,19 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
183
202
  setIsStale(cam.stalled);
184
203
  return cam.on("stall", setIsStale);
185
204
  }, [client, flightId]);
205
+ const [hasSource, setHasSource] = useState(false);
206
+ useEffect(() => {
207
+ if (flightId === null) {
208
+ setHasSource(false);
209
+ return;
210
+ }
211
+ const cam = client.camera(flightId);
212
+ setHasSource(cam.hasSource);
213
+ return cam.on("source", setHasSource);
214
+ }, [client, flightId]);
215
+ /* The in-stream static keeps the video element presenting frames, so a
216
+ frame count cannot tell a picture from static; this attribute can. */
217
+ const signal = flightId === null ? undefined : !hasSource ? "none" : isStale ? "stalled" : "live";
186
218
  // -------------------------------------------------------------------------
187
219
  // Fullscreen + Picture-in-Picture (opt-in, feature-detected)
188
220
  // -------------------------------------------------------------------------
@@ -221,6 +253,21 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
221
253
  v.removeEventListener("leavepictureinpicture", onLeave);
222
254
  };
223
255
  }, [pipAvailable, flightId]);
256
+ /* Browsers usually consume Escape to leave fullscreen themselves; this
257
+ covers hosts and embedded views that hand the key to the page instead. */
258
+ useEffect(() => {
259
+ if (!isFullscreen)
260
+ return;
261
+ const onKey = (e) => {
262
+ if (e.key === "Escape" &&
263
+ !e.defaultPrevented &&
264
+ currentFullscreenElement() === wrapRef.current) {
265
+ exitFullscreen();
266
+ }
267
+ };
268
+ document.addEventListener("keydown", onKey);
269
+ return () => document.removeEventListener("keydown", onKey);
270
+ }, [isFullscreen]);
224
271
  const toggleFullscreen = useCallback(() => {
225
272
  const el = wrapRef.current;
226
273
  if (!el)
@@ -242,6 +289,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
242
289
  }
243
290
  }, []);
244
291
  const isDestroyed = camera ? isCameraDestroyed(camera) : false;
292
+ const waitingForSlot = useKerbcastWaitingForSlot(flightId);
245
293
  /* Gating here rather than at the render sites covers the whole manual
246
294
  path at once: the controls unmount, the effects below zero any in-flight
247
295
  rate, and the imperative handle's pan/zoom methods no-op. */
@@ -259,11 +307,23 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
259
307
  const commandCamera = camera?.kind === CameraKind.Command && !isDestroyed ? camera : null;
260
308
  const isCommandFeed = commandCamera !== null;
261
309
  const commandTrack = commandCamera?.commandTrack ?? null;
262
- const commandDisplayUt = useCommandDisplayUt(commandTrack !== null, displayUtProp, commandTrack?.events, commandCamera?.flightId ?? null, videoEl);
310
+ const commandDisplayUt = useCommandDisplayUt(commandTrack !== null, displayFrame ? displayFrame.ut : displayUtProp, commandTrack?.events, commandCamera?.flightId ?? null, videoEl, displayFrame?.command);
263
311
  const commandState = commandTrack
264
- ? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut)
312
+ ? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut, commandDisplayUt?.command)
265
313
  : null;
266
- const [bracketsOn, toggleBrackets] = useTargetBracketsPreference(commandCamera ? (commandCamera.commandCentre?.stationId ?? String(commandCamera.flightId)) : null);
314
+ /* This feed's anchors go to the camera it displays, and leave with it. */
315
+ const anchorsKey = anchors?.length ? JSON.stringify(anchors) : "";
316
+ useEffect(() => {
317
+ if (flightId === null || !anchorsKey)
318
+ return;
319
+ return claimAnchors(client, flightId, JSON.parse(anchorsKey));
320
+ }, [client, flightId, anchorsKey]);
321
+ const drawsAnchors = !!anchorsKey || !!anchorOverlay?.owners?.length;
322
+ const [bracketsOn, toggleBrackets] = useTargetBracketsPreference(commandCamera
323
+ ? (commandCamera.commandCentre?.siteKey || commandCamera.commandCentre?.nodeName || String(commandCamera.flightId))
324
+ : null,
325
+ // 1.11 keyed the preference by stationId; carry a saved choice over.
326
+ commandCamera?.commandCentre?.stationId ?? null);
267
327
  const targetBracketsAvailable = enableTargetBrackets && isCommandFeed;
268
328
  // -------------------------------------------------------------------------
269
329
  // Camera selection callbacks (Next/Prev, picker)
@@ -489,8 +549,12 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
489
549
  // one aims the camera at it. Hidden while a command authority holds the
490
550
  // aim lock, since only it can aim then.
491
551
  // -------------------------------------------------------------------------
492
- const commandTargets = commandCamera?.commandCentre?.targets ?? [];
493
- const commandTargetId = commandCamera?.commandCentre?.subjectPersistentId ?? 0;
552
+ /* The list as it stood at the displayed frame, so a delayed feed never offers what its picture has not reached. */
553
+ const shownCentre = commandCamera
554
+ ? client.commandCentreAt(commandCamera.flightId, commandDisplayUt?.ut)
555
+ : null;
556
+ const commandTargets = shownCentre?.targets ?? [];
557
+ const commandTargetId = shownCentre?.subjectPersistentId ?? 0;
494
558
  const commandLocked = commandCamera?.commandTrack?.authority != null;
495
559
  const commandTargetAvailable = isCommandFeed && !disableManualControls && !commandLocked && commandTargets.length > 0;
496
560
  const commandTargetMenuId = useId();
@@ -523,6 +587,8 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
523
587
  yet (see ActiveRecordingInfo.arming). Drives the ARMING pill below in
524
588
  place of the REC badge. */
525
589
  const armingActive = activeRecording?.arming === true;
590
+ /* Stopped, clip not in the store yet (ActiveRecordingInfo.stopping). */
591
+ const savingActive = activeRecording?.stopping === true;
526
592
  // Ticks once a second while recording so the elapsed timer stays live;
527
593
  // the timer itself is derived from activeRecording.startedAt each render.
528
594
  const [, tickRecordingTimer] = useReducer((c) => c + 1, 0);
@@ -561,7 +627,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
561
627
  createPortal(_jsx(CameraMenu, { ref: cameraMenu.menuRef, id: menuId, role: "menu", "aria-label": "Camera", style: cameraMenu.position, children: cameras.map((c) => (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": c.flightId === flightId, "$selected": c.flightId === flightId, onClick: () => {
562
628
  onSelectCamera?.(c.flightId);
563
629
  cameraMenu.close();
564
- }, children: [cameraLabel(c), " (", c.vesselName, ")", isCameraDestroyed(c) ? " - signal lost" : ""] }, c.flightId))) }), document.body), showDebugInfo &&
630
+ }, children: [cameraLabel(c), " (", c.vesselName, ")", isCameraDestroyed(c) ? " - signal lost" : ""] }, c.flightId))) }), menuPortalRoot()), showDebugInfo &&
565
631
  (camera ? (_jsxs(TopMeta, { children: [camera.vesselName, " \u00B7 ", camera.renderWidth, "\u00D7", camera.renderHeight, bitrateLabel, adaptiveLabel] })) : (_jsx(TopMeta, { children: "no cameras on this vessel" })))] }));
566
632
  /*
567
633
  * Built-in action-bar entries, in the bar's natural (fallback) order:
@@ -655,8 +721,11 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
655
721
  {
656
722
  id: "fullscreen",
657
723
  label: fullscreenLabel,
724
+ /* While fullscreen the exit stays inline, never in the ⋮ menu,
725
+ so it can be the one control the Stage keeps visible. */
658
726
  stateful: false,
659
- render: () => (_jsx(OverlayIconButton, { type: "button", "aria-label": fullscreenLabel, "aria-pressed": isFullscreen, title: fullscreenLabel, "$active": isFullscreen, onClick: toggleFullscreen, children: isFullscreen ? _jsx(FullscreenExitIcon, {}) : _jsx(FullscreenEnterIcon, {}) })),
727
+ inline: isFullscreen,
728
+ render: () => (_jsx(OverlayIconButton, { type: "button", "data-kc-fullscreen-exit": isFullscreen || undefined, "aria-label": fullscreenLabel, "aria-pressed": isFullscreen, title: fullscreenLabel, "$active": isFullscreen, onClick: toggleFullscreen, children: isFullscreen ? _jsx(FullscreenExitIcon, {}) : _jsx(FullscreenEnterIcon, {}) })),
660
729
  },
661
730
  ]
662
731
  : []),
@@ -691,24 +760,24 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
691
760
  builtInActions);
692
761
  const stepButtons = hasCameras ? (_jsxs(StepButtons, { children: [_jsx(OverlayIconButton, { type: "button", "aria-label": "Previous camera", disabled: !canStep, onClick: () => stepCamera(-1), children: "\u2039" }), _jsx(OverlayIconButton, { type: "button", "aria-label": "Next camera", disabled: !canStep, onClick: () => stepCamera(1), children: "\u203A" })] })) : null;
693
762
  const actionBar = hasActionBar ? (_jsx(FeedActionBar, { leading: stepButtons, entries: entries })) : null;
694
- return (_jsx(FeedBox, { style: feedShape, children: _jsx(Stage, { ref: wrapRef, "$pinned": chromePinned, children: flightId === null ? (_jsxs(_Fragment, { children: [_jsx(Empty, { children: emptyMessage }), topOverlay, actionBar] })) : (_jsxs(_Fragment, { children: [_jsx(StyledVideo, { ref: attachVideo, autoPlay: true, playsInline: true, muted: true, controls: false, onClick: () => setChromePinned((v) => !v) }), topOverlay, actionBar, qualityAvailable &&
763
+ return (_jsx(FeedBox, { style: feedShape, children: _jsx(Stage, { ref: wrapRef, "$pinned": chromePinned, "$fullscreen": isFullscreen, "data-fullscreen": isFullscreen || undefined, "data-kc-signal": signal, children: flightId === null ? (_jsxs(_Fragment, { children: [_jsx(Empty, { children: emptyMessage }), topOverlay, actionBar] })) : (_jsxs(_Fragment, { children: [_jsx(StyledVideo, { ref: attachVideo, autoPlay: true, playsInline: true, muted: true, controls: false, onClick: () => setChromePinned((v) => !v) }), topOverlay, actionBar, qualityAvailable &&
695
764
  qualityMenu.open &&
696
765
  qualityMenu.position &&
697
766
  camera &&
698
- createPortal(_jsxs(QualityMenu, { ref: qualityMenu.menuRef, id: qualityMenuId, role: "menu", "aria-label": "Quality", style: qualityMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality == null, "$selected": camera.viewerQuality == null, onClick: () => selectQuality(null), children: "Auto" }), QUALITY_PRESETS.map(({ preset, label, scale }) => (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality === preset, "$selected": camera.viewerQuality === preset, onClick: () => selectQuality(preset), children: [label, " (", presetDim(camera.operatorWidth, scale), "\u00D7", presetDim(camera.operatorHeight, scale), ")"] }, preset))), _jsxs(QualityMeta, { role: "status", children: ["now ", camera.renderWidth, "\u00D7", camera.renderHeight, qualityThrottled ? " · throttled" : ""] })] }), document.body), trackingAvailable &&
767
+ createPortal(_jsxs(QualityMenu, { ref: qualityMenu.menuRef, id: qualityMenuId, role: "menu", "aria-label": "Quality", style: qualityMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality == null, "$selected": camera.viewerQuality == null, onClick: () => selectQuality(null), children: "Auto" }), QUALITY_PRESETS.map(({ preset, label, scale }) => (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality === preset, "$selected": camera.viewerQuality === preset, onClick: () => selectQuality(preset), children: [label, " (", presetDim(camera.operatorWidth, scale), "\u00D7", presetDim(camera.operatorHeight, scale), ")"] }, preset))), _jsxs(QualityMeta, { role: "status", children: ["now ", camera.renderWidth, "\u00D7", camera.renderHeight, qualityThrottled ? " · throttled" : ""] })] }), menuPortalRoot()), trackingAvailable &&
699
768
  trackingMenu.open &&
700
769
  trackingMenu.position &&
701
770
  camera &&
702
- createPortal(_jsxs(QualityMenu, { ref: trackingMenu.menuRef, id: trackingMenuId, role: "menu", "aria-label": "Auto-track", style: trackingMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.ActiveVessel, "$selected": trackMode === TrackMode.ActiveVessel, onClick: () => selectTrack(TrackMode.ActiveVessel), children: "Track active vessel" }), _jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.Target, "$selected": trackMode === TrackMode.Target, onClick: () => selectTrack(TrackMode.Target), children: "Track target" })] }), document.body), commandTargetAvailable &&
771
+ createPortal(_jsxs(QualityMenu, { ref: trackingMenu.menuRef, id: trackingMenuId, role: "menu", "aria-label": "Auto-track", style: trackingMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.ActiveVessel, "$selected": trackMode === TrackMode.ActiveVessel, onClick: () => selectTrack(TrackMode.ActiveVessel), children: "Track active vessel" }), _jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.Target, "$selected": trackMode === TrackMode.Target, onClick: () => selectTrack(TrackMode.Target), children: "Track target" })] }), menuPortalRoot()), commandTargetAvailable &&
703
772
  commandTargetMenu.open &&
704
773
  commandTargetMenu.position &&
705
774
  createPortal(_jsx(CameraMenu, { ref: commandTargetMenu.menuRef, id: commandTargetMenuId, role: "menu", "aria-label": "Target", style: commandTargetMenu.position, children: commandTargets.map((t) => {
706
775
  const eligible = t.eligibility === CommandTargetEligibility.Ok;
707
776
  const current = t.persistentId === commandTargetId;
708
777
  return (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": current, "$selected": current, disabled: !eligible, title: eligible ? undefined : t.eligibilityText, onClick: () => selectCommandTarget(t.persistentId), children: [t.vesselName, t.rangeM != null ? ` · ${formatCommandRange(t.rangeM)}` : "", eligible ? "" : ` · ${t.eligibilityText}`] }, t.persistentId));
709
- }) }), document.body), commandState &&
778
+ }) }), menuPortalRoot()), commandState &&
710
779
  !outOfFlight &&
711
- (_jsxs(_Fragment, { children: [commandState.blank && _jsx(CommandBlank, { reason: commandState.blank }), targetBracketsAvailable && bracketsOn && (_jsx(CommandBrackets, { state: commandState, displayUt: commandDisplayUt?.ut ?? commandTrack.ut, utSource: commandDisplayUt?.source ?? null, video: videoEl, fallbackSize: camera ? { width: camera.renderWidth, height: camera.renderHeight } : null, overlay: targetBracketOverlay }))] })), outOfFlight ? (showStandbyIcon && (_jsx(StandbyOverlay, { role: "status", "aria-label": "Standby, no active flight", children: _jsx(StandbyIconWrap, { children: _jsx(StandbyIcon, { size: 40 }) }) }))) : (isDestroyed && (_jsx(SignalLostOverlay, { role: "status", "aria-label": "Signal lost", children: _jsx(SignalLostText, { "$animated": effectiveShowStatic, children: "SIGNAL LOST" }) }))), !outOfFlight && !effectiveShowStatic && isStale && !isDestroyed && (_jsxs(_Fragment, { children: [_jsx(StaleScrim, { "aria-hidden": "true" }), _jsxs(StaleBadge, { role: "status", "aria-label": "Feed stale", children: [_jsx(StaleIcon, {}), "Stale"] })] })), recordingActive && armingActive && (_jsxs(ArmingBadge, { role: "status", "aria-label": "Arming", children: [_jsx(ArmingDot, { "aria-hidden": "true" }), "ARMING"] })), recordingActive && !armingActive && (_jsxs(RecBadge, { role: "status", "aria-label": "Recording", children: [_jsx(RecDot, { "aria-hidden": "true" }), "REC ", formatElapsed(recordingElapsedMs)] })), showZoom && (_jsxs(ZoomControlsWrap, { "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(ZoomButton, { type: "button", "aria-label": "Zoom in", "$pos": "top", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
780
+ (_jsxs(_Fragment, { children: [commandState.blank && _jsx(CommandBlank, { reason: commandState.blank }), commandState.waitingForTarget && !commandState.blank && !isDestroyed && waitingForTargetLabel && (_jsx(WaitingBadge, { role: "status", children: waitingForTargetLabel })), targetBracketsAvailable && bracketsOn && (_jsx(LiveCommandBrackets, { flightId: commandCamera?.flightId ?? null, state: commandState, displayUt: commandDisplayUt?.ut ?? commandTrack.ut, utSource: commandDisplayUt?.source ?? null, video: videoEl, fallbackSize: camera ? { width: camera.renderWidth, height: camera.renderHeight } : null, overlay: targetBracketOverlay }))] })), waitingForSlot && !outOfFlight && !isDestroyed && noSlotLabel && (_jsx(NoSlotNotice, { role: "status", children: noSlotLabel })), drawsAnchors && !outOfFlight && !isDestroyed && !commandState?.blank && (_jsx(LiveAnchorMarkers, { flightId: flightId, video: videoEl, overlay: anchorOverlay })), outOfFlight ? (showStandbyIcon && (_jsx(StandbyOverlay, { role: "status", "aria-label": "Standby, no active flight", children: _jsx(StandbyIconWrap, { children: _jsx(StandbyIcon, { size: 40 }) }) }))) : (isDestroyed && (_jsx(SignalLostOverlay, { role: "status", "aria-label": "Signal lost", children: _jsx(SignalLostText, { "$animated": effectiveShowStatic, children: "SIGNAL LOST" }) }))), !outOfFlight && !effectiveShowStatic && isStale && !isDestroyed && (_jsxs(_Fragment, { children: [_jsx(StaleScrim, { "aria-hidden": "true" }), _jsxs(StaleBadge, { role: "status", "aria-label": "Feed stale", children: [_jsx(StaleIcon, {}), "Stale"] })] })), recordingActive && armingActive && (_jsxs(ArmingBadge, { role: "status", "aria-label": "Arming", children: [_jsx(ArmingDot, { "aria-hidden": "true" }), "ARMING"] })), recordingActive && !armingActive && !savingActive && (_jsxs(RecBadge, { role: "status", "aria-label": "Recording", children: [_jsx(RecDot, { "aria-hidden": "true" }), "REC ", formatElapsed(recordingElapsedMs)] })), recordingActive && savingActive && (_jsx(RecBadge, { role: "status", "aria-label": "Saving recording", children: "SAVING" })), showZoom && (_jsxs(ZoomControlsWrap, { "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(ZoomButton, { type: "button", "aria-label": "Zoom in", "$pos": "top", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
712
781
  if (e.detail === 0)
713
782
  controllerRef.current?.nudgeZoom(-1);
714
783
  }, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
@@ -742,7 +811,7 @@ export const CameraFeed = forwardRef(function CameraFeed({ client, ...rest }, re
742
811
  return _jsx(CameraFeedInner, { ref: ref, ...rest });
743
812
  });
744
813
  // ---------------------------------------------------------------------------
745
- // Inline UI primitives (replacing @gonogo/ui dependencies)
814
+ // Inline UI primitives
746
815
  // ---------------------------------------------------------------------------
747
816
  function ChevronDownIcon(props) {
748
817
  return (_jsx("svg", { viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": "true", ...props, children: _jsx("path", { d: "M4 6l4 4 4-4", stroke: "currentColor", strokeWidth: 1.5, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" }) }));
@@ -1011,8 +1080,9 @@ const StepButtons = styled.div `
1011
1080
  flex-shrink: 0;
1012
1081
  `;
1013
1082
  const CameraMenu = styled.div `
1014
- /* Portaled to document.body: fixed position (set inline from the trigger
1015
- rect) keeps it clear of the tile's overflow clipping. */
1083
+ /* Portaled to document.body (the fullscreen element while fullscreen):
1084
+ fixed position (set inline from the trigger rect) keeps it clear of the
1085
+ tile's overflow clipping. */
1016
1086
  position: fixed;
1017
1087
  z-index: 1000;
1018
1088
  max-width: min(260px, calc(100vw - 16px));
@@ -1068,7 +1138,7 @@ const TopMeta = styled.div `
1068
1138
  color: rgba(255, 255, 255, 0.78);
1069
1139
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
1070
1140
  `;
1071
- /* The quality picker: portaled to document.body like CameraMenu, hung from
1141
+ /* The quality picker: portaled like CameraMenu, hung from
1072
1142
  its action-bar trigger (fixed position set inline from the trigger rect)
1073
1143
  so tile clipping cannot cut it off and it survives the hover-revealed
1074
1144
  chrome fading while the pointer is over the menu. */
@@ -1285,6 +1355,30 @@ const Stage = styled(Panel) `
1285
1355
  opacity: 1;
1286
1356
  }
1287
1357
  `}
1358
+
1359
+ /* Fullscreen has no hover on touch and may have no pointer at all, so the
1360
+ exit button stays visible in its usual slot while the rest of the bar
1361
+ keeps its hover/focus/tap reveal. */
1362
+ ${(p) => p.$fullscreen &&
1363
+ !p.$pinned &&
1364
+ css `
1365
+ ${ActionBarRow} {
1366
+ opacity: 1;
1367
+ pointer-events: auto;
1368
+ }
1369
+ ${ActionBarRow} > * {
1370
+ transition: opacity 0.15s;
1371
+ }
1372
+ &:not(:hover):not(:focus-within) ${ActionBarRow} > :not([data-kc-fullscreen-exit]) {
1373
+ opacity: 0;
1374
+ pointer-events: none;
1375
+ }
1376
+ @media (prefers-reduced-motion: reduce) {
1377
+ ${ActionBarRow} > * {
1378
+ transition: none;
1379
+ }
1380
+ }
1381
+ `}
1288
1382
  `;
1289
1383
  const Empty = styled.div `
1290
1384
  color: #888;
@@ -1366,6 +1460,52 @@ const StaleScrim = styled.div `
1366
1460
  background: rgba(0, 0, 0, 0.32);
1367
1461
  pointer-events: none;
1368
1462
  `;
1463
+ /* Centred in the picture, clear of the chrome along the edges, and wrapping so
1464
+ a small tile still shows the whole sentence. */
1465
+ const NoSlotNotice = styled.div `
1466
+ position: absolute;
1467
+ top: 50%;
1468
+ left: 50%;
1469
+ transform: translate(-50%, -50%);
1470
+ z-index: 2;
1471
+ max-width: calc(100% - 32px);
1472
+ padding: 6px 10px;
1473
+ background: rgba(0, 0, 0, 0.6);
1474
+ border: 1px solid rgba(255, 255, 255, 0.4);
1475
+ border-radius: 3px;
1476
+ color: rgba(255, 255, 255, 0.9);
1477
+ font-size: 12px;
1478
+ line-height: 1.4;
1479
+ text-align: center;
1480
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
1481
+ pointer-events: none;
1482
+ `;
1483
+ /* Bottom centre: the zoom controls take the bottom left, pan and the stale
1484
+ badge the bottom right, the title and REC badge the top left and the
1485
+ action bar the top right. */
1486
+ const WaitingBadge = styled.div `
1487
+ position: absolute;
1488
+ bottom: 8px;
1489
+ left: 50%;
1490
+ transform: translateX(-50%);
1491
+ z-index: 2;
1492
+ /* Clear of the stale badge on the bottom right, even on a small tile. */
1493
+ max-width: calc(100% - 180px);
1494
+ overflow: hidden;
1495
+ text-overflow: ellipsis;
1496
+ white-space: nowrap;
1497
+ padding: 2px 6px;
1498
+ background: rgba(0, 0, 0, 0.6);
1499
+ border: 1px solid rgba(255, 255, 255, 0.4);
1500
+ border-radius: 3px;
1501
+ color: rgba(255, 255, 255, 0.9);
1502
+ font-size: 10px;
1503
+ font-weight: 600;
1504
+ letter-spacing: 0.12em;
1505
+ text-transform: uppercase;
1506
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
1507
+ pointer-events: none;
1508
+ `;
1369
1509
  const StaleBadge = styled.div `
1370
1510
  position: absolute;
1371
1511
  bottom: 8px;
@@ -1,4 +1,4 @@
1
- import { type EffectiveCommandTrack, type FrameUtSource } from "@ksp-gonogo/kerbcast";
1
+ import { type EffectiveCommandTrack, type FrameAnchor, type FrameUtSource } from "@ksp-gonogo/kerbcast";
2
2
  import { type ReactNode } from "react";
3
3
  /** Default colours; each is overridable through the named CSS custom property. */
4
4
  export declare const COMMAND_BRACKET_COLOURS: {
@@ -22,7 +22,10 @@ export interface BracketPoint {
22
22
  }
23
23
  /** The bracket box, in the pixel space of the bracket SVG (the video element's box). */
24
24
  export interface BracketGeometry {
25
- /** Box centre, which is the aim point. */
25
+ /**
26
+ * Box centre: where the subject is in the displayed frame, or the frame
27
+ * centre (the aim point) while that is not known.
28
+ */
26
29
  cx: number;
27
30
  cy: number;
28
31
  /** Half the box side in display px. */
@@ -35,11 +38,12 @@ export interface BracketGeometry {
35
38
  bottomRight: BracketPoint;
36
39
  }
37
40
  /**
38
- * The bracket box centred on the displayed frame. `bracketHalfPx` is in
39
- * capture px of a `frameHeightPx`-tall frame, so it scales by the displayed
40
- * height; a missing half-size draws the minimum box.
41
+ * The bracket box, centred on the displayed frame or on `centre` (in the
42
+ * same pixel space as `rect`). `bracketHalfPx` is in capture px of a
43
+ * `frameHeightPx`-tall frame, so it scales by the displayed height; a
44
+ * missing half-size draws the minimum box.
41
45
  */
42
- export declare function bracketGeometry(rect: ContentRect, bracketHalfPx: number | null, frameHeightPx: number): BracketGeometry;
46
+ export declare function bracketGeometry(rect: ContentRect, bracketHalfPx: number | null, frameHeightPx: number, centre?: BracketPoint | null): BracketGeometry;
43
47
  export type CommandBracketCorner = "topLeft" | "bottomRight";
44
48
  export interface CommandBracketCornerStyle {
45
49
  colour: string;
@@ -62,6 +66,19 @@ export interface CommandBracketContext {
62
66
  displayUt: number;
63
67
  utSource: FrameUtSource | "override" | null;
64
68
  state: EffectiveCommandTrack;
69
+ /**
70
+ * The subject's anchor in the displayed frame: where the game projected
71
+ * the craft, whether it is in frame and whether something hides it.
72
+ * `null` without one (an older sidecar or plugin, or before the first
73
+ * stamped frame), when the box is centred on the frame instead.
74
+ */
75
+ subject?: FrameAnchor | null;
76
+ /**
77
+ * True while the aim lies off the frame (the view is still turning onto
78
+ * it): the box then sits on the frame's edge nearest the aim, with a
79
+ * chevron pointing on toward it.
80
+ */
81
+ aimOffscreen?: boolean;
65
82
  }
66
83
  /**
67
84
  * Consumer additions to a command feed's brackets. Each part is optional and
@@ -95,15 +112,47 @@ export interface CommandBracketsProps {
95
112
  height: number;
96
113
  } | null;
97
114
  overlay?: CommandBracketOverlay | null;
115
+ /**
116
+ * The subject's anchor in the displayed frame. While it is visible and no
117
+ * command lock is held the box sits on it and takes its size from it.
118
+ */
119
+ subject?: FrameAnchor | null;
120
+ /** The capture frame size `subject` is in (the stamp's width and height). */
121
+ subjectFrameSize?: {
122
+ width: number;
123
+ height: number;
124
+ } | null;
125
+ /**
126
+ * The aim anchor in the displayed frame. The aimed box sits on it rather
127
+ * than on the frame centre, so it stays on the aim while the view turns
128
+ * onto a moved one.
129
+ */
130
+ aim?: FrameAnchor | null;
98
131
  }
99
132
  /**
100
- * Target brackets for a command camera feed: the aim point sits at frame
101
- * centre, so the box is always centred on the displayed video. Both corners
102
- * show the camera axis by default; an overlay can restyle either and draw
103
- * its own marks. Without a fix, or over a blanked feed, the default corners
104
- * are left out but the overlay and readout still run.
133
+ * Target brackets for a command camera feed. With no command lock held and
134
+ * the subject's anchor visible in the displayed frame, the box sits on the
135
+ * craft where the game saw it, so it stays on the craft when an aim points
136
+ * elsewhere; while the aimed box is in frame and holds the craft it is drawn
137
+ * instead, since it frames the craft's whole extent as the view does. Otherwise (the anchor out of frame or occluded, which then
138
+ * carries no position, no anchor at all, or a command authority holding the
139
+ * lock) the box is the 1.11 one: on the aim (the frame centre, or the aim
140
+ * anchor while the view turns onto a moved aim), sized from the aimed range,
141
+ * red and dashed when the aim line is occluded. `ctx.subject` always
142
+ * carries the anchor, so a consumer can draw it as a second mark. An overlay
143
+ * can restyle either corner and draw its own marks. Without a fix, or over a
144
+ * blanked feed, the default corners are left out but the overlay and readout
145
+ * still run.
146
+ */
147
+ export declare function CommandBrackets({ state, displayUt, utSource, video, fallbackSize, overlay, subject, subjectFrameSize, aim, }: CommandBracketsProps): import("react").JSX.Element;
148
+ /**
149
+ * CommandBrackets fed with the subject's and the aim's anchors from each frame of
150
+ * `flightId` that `video` presents. Re-renders at the frame rate, which is
151
+ * why it is its own component.
105
152
  */
106
- export declare function CommandBrackets({ state, displayUt, utSource, video, fallbackSize, overlay, }: CommandBracketsProps): import("react").JSX.Element;
153
+ export declare function LiveCommandBrackets({ flightId, ...props }: CommandBracketsProps & {
154
+ flightId: number | null;
155
+ }): import("react").JSX.Element;
107
156
  /** Reason a command feed is blanked, centred on black over the feed. */
108
157
  export declare function CommandBlank({ reason }: {
109
158
  reason: string;