@ksp-gonogo/kerbcast-react 1.10.3 → 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 CHANGED
@@ -68,6 +68,19 @@ comments):
68
68
  feed, and whether it draws its own standby icon (set `false` when a
69
69
  container renders a single shared out-of-flight overlay).
70
70
  - `useStream`. Override how the stream is sourced. See below.
71
+ - `renderSize`. `"auto"` (the default) measures the feed and asks the
72
+ sidecar for a matching resolution; `"none"` leaves resolution alone.
73
+
74
+ `renderSize="auto"` picks the resolution, never the shape. The feed takes
75
+ the camera's configured capture aspect (`CameraState.maxWidth` x
76
+ `maxHeight`, from settings.cfg; 16:9 when unknown), centres itself at that
77
+ aspect inside whatever box you give it, and asks for the size of that
78
+ picture: width rounded up to a multiple of 16, height at the aspect. A
79
+ resize that only follows the incoming stream is never reported back, so a
80
+ frame you size from the video (or from `renderWidth`/`renderHeight`)
81
+ cannot loop with the request. A resize within about a second of the stream
82
+ changing that matches its new shape counts as following it. Layout changes
83
+ outside that window always report.
71
84
 
72
85
  An imperative `CameraFeedHandle` (via `ref`) exposes `stepCamera`,
73
86
  `setPanAxis`, `setZoomRate`, `nudgePan`, and `nudgeZoom` for wiring feeds
@@ -89,10 +102,12 @@ quality follows how large it is shown (pass `reportSize={false}` to opt out).
89
102
 
90
103
  ## Hooks
91
104
 
92
- - `useReportDisplaySize(flightId, ref)`. Self-measure an element and drive the
93
- client's per-consumer `reportDisplaySize` (auto-resolution). The feed
94
- primitives use it internally; multiple views of one camera from a client
95
- collapse to a single MAX report.
105
+ - `useReportDisplaySize(flightId, ref, options)`. Self-measure an element and
106
+ drive the client's per-consumer `reportDisplaySize` (auto-resolution). The
107
+ feed primitives use it internally; multiple views of one camera from a
108
+ client collapse to a single MAX report. Pass `aspect` for the shape to ask
109
+ for (default 16:9), and `videoRef` / `streamSize` so a resize that follows
110
+ the stream is not reported.
96
111
 
97
112
  - `useKerbcastCameras(): CameraState[]`. The live camera registry,
98
113
  re-rendering as cameras appear, change, or are destroyed.
@@ -1,5 +1,6 @@
1
1
  import type { CameraState, KerbcastClient } from "@ksp-gonogo/kerbcast";
2
2
  import { type FeedAction } from "./FeedActionBar";
3
+ import { type CommandBracketOverlay } from "./CommandBrackets";
3
4
  /**
4
5
  * The consumer-supplied action shape, and the shared top-right action bar
5
6
  * both `CameraFeed` and `KerbalFaceFeed` render, live in `FeedActionBar`.
@@ -186,6 +187,26 @@ export interface CameraFeedProps {
186
187
  * {@link CameraStreamHook}.
187
188
  */
188
189
  useStream?: CameraStreamHook;
190
+ /**
191
+ * Draw target brackets and their readout over command camera feeds, with a
192
+ * per-feed "Target brackets" toggle in the action bar (on by default,
193
+ * remembered per camera in this browser). Default true. A blanked command
194
+ * feed shows its reason whatever this is set to.
195
+ */
196
+ enableTargetBrackets?: boolean;
197
+ /**
198
+ * Extra drawing, corner styling and readout text for this feed's target
199
+ * brackets. See {@link CommandBracketOverlay}.
200
+ */
201
+ targetBracketOverlay?: CommandBracketOverlay;
202
+ /**
203
+ * Mission UT the displayed video was captured at, for a host running its
204
+ * own delayed playout. Picks which command-track transition is in force and
205
+ * ages the readout. Defaults to the displayed frame's own capture UT where
206
+ * the browser reports frame RTP timestamps, else the client's capture
207
+ * clock interpolated to now.
208
+ */
209
+ displayUt?: number;
189
210
  }
190
211
  export interface CameraFeedHandle {
191
212
  stepCamera(delta: number): void;
@@ -1,16 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { PanZoomController, QualityPreset, TrackMode } from "@ksp-gonogo/kerbcast";
2
+ import { CameraKind, CommandAimKind, CommandTargetEligibility, PanZoomController, QualityPreset, TrackMode, effectiveCommandTrack, formatCommandRange, } from "@ksp-gonogo/kerbcast";
3
3
  import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useReducer, useRef, useState, } from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  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";
10
+ import { useCommandDisplayUt } from "./hooks/useCommandDisplayUt";
9
11
  import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
10
12
  import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
11
13
  import { useKerbcastStream } from "./hooks/useKerbcastStream";
12
14
  import { useRecordings } from "./hooks/useRecordings";
13
- import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
15
+ import { DEFAULT_FEED_ASPECT, useReportDisplaySize } from "./hooks/useReportDisplaySize";
16
+ import { useTargetBracketsPreference } from "./hooks/useTargetBracketsPreference";
14
17
  import { StandbyIcon } from "./StandbyIcon";
15
18
  import { isCameraDestroyed } from "./lifecycle";
16
19
  import { usePortalMenu } from "./menuPositioning";
@@ -59,7 +62,7 @@ function exitFullscreen() {
59
62
  // ---------------------------------------------------------------------------
60
63
  // Inner component (reads from context)
61
64
  // ---------------------------------------------------------------------------
62
- 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, }, ref) {
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) {
63
66
  const client = useKerbcastClient();
64
67
  // Always called (rules of hooks); `enableRecording` only gates whether the
65
68
  // REC entry/badge render, not whether the store is subscribed.
@@ -146,6 +149,13 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
146
149
  const resolveStream = useStream ?? useKerbcastStream;
147
150
  const stream = resolveStream(flightId);
148
151
  const videoRef = useRef(null);
152
+ /* Mirrors videoRef as state so the bracket overlay can measure the element
153
+ once it mounts. Setting a ref never remounts the video. */
154
+ const [videoEl, setVideoEl] = useState(null);
155
+ const attachVideo = useCallback((el) => {
156
+ videoRef.current = el;
157
+ setVideoEl(el);
158
+ }, []);
149
159
  // The feed frame; fullscreen targets this and ResizeObserver measures it.
150
160
  const wrapRef = useRef(null);
151
161
  useEffect(() => {
@@ -245,6 +255,16 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
245
255
  const tracking = trackMode !== TrackMode.None;
246
256
  const manualDisabled = tracking;
247
257
  const supportsPitch = !!camera && camera.panPitchMax - camera.panPitchMin > 0;
258
+ /* Command camera feeds: target brackets, readout and blank label. */
259
+ const commandCamera = camera?.kind === CameraKind.Command && !isDestroyed ? camera : null;
260
+ const isCommandFeed = commandCamera !== null;
261
+ const commandTrack = commandCamera?.commandTrack ?? null;
262
+ const commandDisplayUt = useCommandDisplayUt(commandTrack !== null, displayUtProp, commandTrack?.events, commandCamera?.flightId ?? null, videoEl);
263
+ const commandState = commandTrack
264
+ ? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut)
265
+ : null;
266
+ const [bracketsOn, toggleBrackets] = useTargetBracketsPreference(commandCamera ? (commandCamera.commandCentre?.stationId ?? String(commandCamera.flightId)) : null);
267
+ const targetBracketsAvailable = enableTargetBrackets && isCommandFeed;
248
268
  // -------------------------------------------------------------------------
249
269
  // Camera selection callbacks (Next/Prev, picker)
250
270
  // onSelectCamera is ONLY called on explicit user picks, never on auto-latch.
@@ -371,10 +391,29 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
371
391
  // -------------------------------------------------------------------------
372
392
  // Display-size reporting (auto-resolution). The feed self-measures its
373
393
  // rendered box and reports it per-consumer; the sidecar maxes across
374
- // consumers. Reports the real w x h (part cams are not square). Disabled by
375
- // `renderSize="none"`. See useReportDisplaySize for debounce / bucketing.
394
+ // consumers. Auto picks the resolution, never the shape: the request and
395
+ // the feed's own box keep the camera's configured capture aspect (16:9
396
+ // when unknown). Disabled by `renderSize="none"`. The video and the
397
+ // sidecar's render size let it ignore a resize that only follows the
398
+ // stream. See useReportDisplaySize for debounce / bucketing / the guard.
376
399
  // -------------------------------------------------------------------------
377
- useReportDisplaySize(flightId, wrapRef, { enabled: renderSize === "auto" });
400
+ const baseW = camera?.maxWidth ?? 0;
401
+ const baseH = camera?.maxHeight ?? 0;
402
+ const hasBase = baseW > 0 && baseH > 0;
403
+ const feedAspect = hasBase ? baseW / baseH : DEFAULT_FEED_ASPECT;
404
+ const feedShape = useMemo(() => ({
405
+ "--kc-feed-w": hasBase ? baseW : 16,
406
+ "--kc-feed-h": hasBase ? baseH : 9,
407
+ }), [hasBase, baseW, baseH]);
408
+ const renderedW = camera?.renderWidth ?? 0;
409
+ const renderedH = camera?.renderHeight ?? 0;
410
+ const renderedSize = useMemo(() => ({ width: renderedW, height: renderedH }), [renderedW, renderedH]);
411
+ useReportDisplaySize(flightId, wrapRef, {
412
+ enabled: renderSize === "auto",
413
+ aspect: feedAspect,
414
+ videoRef,
415
+ streamSize: renderedSize,
416
+ });
378
417
  // -------------------------------------------------------------------------
379
418
  // UI state
380
419
  // -------------------------------------------------------------------------
@@ -444,6 +483,32 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
444
483
  closeTrackingMenu();
445
484
  }, [client, flightId, trackMode, closeTrackingMenu]);
446
485
  // -------------------------------------------------------------------------
486
+ // Command camera target picker. Lists every craft the game offers; the
487
+ // ineligible ones stay visible but disabled with the game's reason, and
488
+ // the current target follows the published subjectPersistentId. Picking
489
+ // one aims the camera at it. Hidden while a command authority holds the
490
+ // aim lock, since only it can aim then.
491
+ // -------------------------------------------------------------------------
492
+ const commandTargets = commandCamera?.commandCentre?.targets ?? [];
493
+ const commandTargetId = commandCamera?.commandCentre?.subjectPersistentId ?? 0;
494
+ const commandLocked = commandCamera?.commandTrack?.authority != null;
495
+ const commandTargetAvailable = isCommandFeed && !disableManualControls && !commandLocked && commandTargets.length > 0;
496
+ const commandTargetMenuId = useId();
497
+ const commandTargetMenu = usePortalMenu({
498
+ maxWidth: MENU_MAX_WIDTH,
499
+ maxHeight: MENU_MAX_HEIGHT,
500
+ align: "end",
501
+ });
502
+ const closeCommandTargetMenu = commandTargetMenu.close;
503
+ const selectCommandTarget = useCallback((persistentId) => {
504
+ if (flightId === null)
505
+ return;
506
+ client
507
+ .setCommandAim(flightId, { kind: CommandAimKind.Vessel, persistentId })
508
+ .catch(() => { });
509
+ closeCommandTargetMenu();
510
+ }, [client, flightId, closeCommandTargetMenu]);
511
+ // -------------------------------------------------------------------------
447
512
  // REC control (opt-in). A stateful toggle that starts/stops a client-side
448
513
  // recording of this feed via the recordings store; state is read from the
449
514
  // store (isRecording), never held locally, so it stays correct if the
@@ -514,6 +579,11 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
514
579
  const pipLabel = isPip ? "Exit picture in picture" : "Picture in picture";
515
580
  const fullscreenLabel = isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
516
581
  const recordingLabel = recordingActive ? "Stop recording" : "Start recording";
582
+ const bracketsLabel = "Target brackets";
583
+ const commandTargetName = commandTargets.find((t) => t.persistentId === commandTargetId)?.vesselName;
584
+ const commandTargetLabel = commandTargetName
585
+ ? `Watching ${commandTargetName}`
586
+ : "Choose target";
517
587
  const entries = [
518
588
  ...(actions?.map(feedActionToEntry) ?? []),
519
589
  ...(qualityAvailable
@@ -521,7 +591,8 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
521
591
  {
522
592
  id: "quality",
523
593
  label: "Quality",
524
- stateful: false,
594
+ /* Stays inline: its menu anchors on this button, which the overflow menu unmounts on click. */
595
+ stateful: true,
525
596
  render: () => (_jsxs(OverlayIconButton, { ref: qualityMenu.triggerRef, type: "button", "aria-label": "Quality", "aria-haspopup": "menu", "aria-expanded": qualityMenu.open, "aria-controls": qualityMenuId, title: qualityThrottled
526
597
  ? "Quality (throttled by adaptive performance)"
527
598
  : "Quality", "$active": qualityMenu.open, onClick: qualityMenu.toggle, children: [_jsx(QualityIcon, {}), qualityThrottled && _jsx(ThrottledDot, { "aria-hidden": "true" })] })),
@@ -538,6 +609,27 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
538
609
  },
539
610
  ]
540
611
  : []),
612
+ ...(targetBracketsAvailable
613
+ ? [
614
+ {
615
+ id: "target-brackets",
616
+ label: bracketsLabel,
617
+ stateful: true,
618
+ render: () => (_jsx(OverlayIconButton, { type: "button", "aria-label": bracketsLabel, "aria-pressed": bracketsOn, title: bracketsLabel, "$active": bracketsOn, onClick: toggleBrackets, children: _jsx(TargetBracketsIcon, {}) })),
619
+ },
620
+ ]
621
+ : []),
622
+ ...(commandTargetAvailable
623
+ ? [
624
+ {
625
+ id: "command-target",
626
+ label: commandTargetLabel,
627
+ /* Stays inline: its menu anchors on this button, which the overflow menu unmounts on click. */
628
+ stateful: true,
629
+ render: () => (_jsx(OverlayIconButton, { ref: commandTargetMenu.triggerRef, type: "button", "aria-label": "Choose target", "aria-haspopup": "menu", "aria-expanded": commandTargetMenu.open, "aria-controls": commandTargetMenuId, title: commandTargetLabel, "$active": commandTargetMenu.open, onClick: commandTargetMenu.toggle, children: _jsx(CommandTargetIcon, {}) })),
630
+ },
631
+ ]
632
+ : []),
541
633
  ...(trackingAvailable
542
634
  ? [
543
635
  {
@@ -589,6 +681,8 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
589
681
  fullscreenAvailable ||
590
682
  qualityAvailable ||
591
683
  trackingAvailable ||
684
+ targetBracketsAvailable ||
685
+ commandTargetAvailable ||
592
686
  enableRecording);
593
687
  const hasActionBar = showActions &&
594
688
  (hasCameras ||
@@ -597,32 +691,41 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
597
691
  builtInActions);
598
692
  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;
599
693
  const actionBar = hasActionBar ? (_jsx(FeedActionBar, { leading: stepButtons, entries: entries })) : null;
600
- return (_jsx(Stage, { ref: wrapRef, "$pinned": chromePinned, children: flightId === null ? (_jsxs(_Fragment, { children: [_jsx(Empty, { children: emptyMessage }), topOverlay, actionBar] })) : (_jsxs(_Fragment, { children: [_jsx(StyledVideo, { ref: videoRef, autoPlay: true, playsInline: true, muted: true, controls: false, onClick: () => setChromePinned((v) => !v) }), topOverlay, actionBar, qualityAvailable &&
601
- qualityMenu.open &&
602
- qualityMenu.position &&
603
- camera &&
604
- 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 &&
605
- trackingMenu.open &&
606
- trackingMenu.position &&
607
- camera &&
608
- 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), 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) => {
609
- if (e.detail === 0)
610
- controllerRef.current?.nudgeZoom(-1);
611
- }, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
612
- const v = Number(e.target.value);
613
- controllerRef.current?.fovSliderInput(v);
614
- }, onPointerDown: () => {
615
- controllerRef.current?.setFovSliderDragging(true);
616
- }, onPointerUp: () => {
617
- controllerRef.current?.setFovSliderDragging(false);
618
- }, onPointerCancel: () => {
619
- controllerRef.current?.setFovSliderDragging(false);
620
- } }), _jsx(ZoomButton, { type: "button", "aria-label": "Zoom out", "$pos": "bottom", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(-1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
621
- if (e.detail === 0)
622
- controllerRef.current?.nudgeZoom(1);
623
- }, children: "\u2212" })] })), showPan && (_jsxs(PanControl, { role: "group", "aria-label": "Pan camera", "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(PanArrow, { type: "button", "$dir": "up", "aria-label": "Pan up", disabled: manualDisabled || !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, 1), children: "\u25B2" }), _jsx(PanArrow, { type: "button", "$dir": "down", "aria-label": "Pan down", disabled: manualDisabled || !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, -1), children: "\u25BC" }), _jsx(PanArrow, { type: "button", "$dir": "left", "aria-label": "Pan left", disabled: manualDisabled, onClick: () => controllerRef.current?.nudgePan(-1, 0), children: "\u25C0" }), _jsx(PanArrow, { type: "button", "$dir": "right", "aria-label": "Pan right", disabled: manualDisabled, onClick: () => controllerRef.current?.nudgePan(1, 0), children: "\u25B6" }), _jsx(PanBall, { "aria-hidden": "true", title: "Drag to pan", onPointerDown: manualDisabled ? undefined : handleBallDown, onPointerMove: manualDisabled ? undefined : handleBallMove, onPointerUp: manualDisabled ? undefined : handleBallUp, onPointerCancel: manualDisabled ? undefined : handleBallUp, style: {
624
- transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
625
- } })] }))] })) }));
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 &&
695
+ qualityMenu.open &&
696
+ qualityMenu.position &&
697
+ 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 &&
699
+ trackingMenu.open &&
700
+ trackingMenu.position &&
701
+ 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 &&
703
+ commandTargetMenu.open &&
704
+ commandTargetMenu.position &&
705
+ createPortal(_jsx(CameraMenu, { ref: commandTargetMenu.menuRef, id: commandTargetMenuId, role: "menu", "aria-label": "Target", style: commandTargetMenu.position, children: commandTargets.map((t) => {
706
+ const eligible = t.eligibility === CommandTargetEligibility.Ok;
707
+ const current = t.persistentId === commandTargetId;
708
+ 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 &&
710
+ !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) => {
712
+ if (e.detail === 0)
713
+ controllerRef.current?.nudgeZoom(-1);
714
+ }, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
715
+ const v = Number(e.target.value);
716
+ controllerRef.current?.fovSliderInput(v);
717
+ }, onPointerDown: () => {
718
+ controllerRef.current?.setFovSliderDragging(true);
719
+ }, onPointerUp: () => {
720
+ controllerRef.current?.setFovSliderDragging(false);
721
+ }, onPointerCancel: () => {
722
+ controllerRef.current?.setFovSliderDragging(false);
723
+ } }), _jsx(ZoomButton, { type: "button", "aria-label": "Zoom out", "$pos": "bottom", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(-1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
724
+ if (e.detail === 0)
725
+ controllerRef.current?.nudgeZoom(1);
726
+ }, children: "\u2212" })] })), showPan && (_jsxs(PanControl, { role: "group", "aria-label": "Pan camera", "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(PanArrow, { type: "button", "$dir": "up", "aria-label": "Pan up", disabled: manualDisabled || !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, 1), children: "\u25B2" }), _jsx(PanArrow, { type: "button", "$dir": "down", "aria-label": "Pan down", disabled: manualDisabled || !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, -1), children: "\u25BC" }), _jsx(PanArrow, { type: "button", "$dir": "left", "aria-label": "Pan left", disabled: manualDisabled, onClick: () => controllerRef.current?.nudgePan(-1, 0), children: "\u25C0" }), _jsx(PanArrow, { type: "button", "$dir": "right", "aria-label": "Pan right", disabled: manualDisabled, onClick: () => controllerRef.current?.nudgePan(1, 0), children: "\u25B6" }), _jsx(PanBall, { "aria-hidden": "true", title: "Drag to pan", onPointerDown: manualDisabled ? undefined : handleBallDown, onPointerMove: manualDisabled ? undefined : handleBallMove, onPointerUp: manualDisabled ? undefined : handleBallUp, onPointerCancel: manualDisabled ? undefined : handleBallUp, style: {
727
+ transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
728
+ } })] }))] })) }) }));
626
729
  });
627
730
  // ---------------------------------------------------------------------------
628
731
  // Public CameraFeed - wraps inner in a nested provider when client prop given
@@ -665,6 +768,14 @@ function FullscreenExitIcon() {
665
768
  function QualityIcon() {
666
769
  return (_jsxs("svg", { ...iconProps, children: [_jsx("path", { d: "M2 4.5h12M2 8h12M2 11.5h12" }), _jsx("circle", { cx: "10.5", cy: "4.5", r: "1.6", fill: "currentColor", stroke: "none" }), _jsx("circle", { cx: "5.5", cy: "8", r: "1.6", fill: "currentColor", stroke: "none" }), _jsx("circle", { cx: "8.5", cy: "11.5", r: "1.6", fill: "currentColor", stroke: "none" })] }));
667
770
  }
771
+ /* Target brackets: the top-left and bottom-right corners around a centre dot. */
772
+ function TargetBracketsIcon() {
773
+ return (_jsxs("svg", { ...iconProps, children: [_jsx("path", { d: "M2.5 7V2.5H7M13.5 9v4.5H9" }), _jsx("circle", { cx: "8", cy: "8", r: "1.2", fill: "currentColor", stroke: "none" })] }));
774
+ }
775
+ /* Command camera target: a craft silhouette inside a selection ring. */
776
+ function CommandTargetIcon() {
777
+ return (_jsxs("svg", { ...iconProps, children: [_jsx("circle", { cx: "8", cy: "8", r: "6" }), _jsx("path", { d: "M8 4.5l1.6 4H6.4zM6.6 8.5l-.9 2.5M9.4 8.5l.9 2.5" })] }));
778
+ }
668
779
  /* Auto-track: a crosshair / reticle. */
669
780
  function CrosshairIcon() {
670
781
  return (_jsxs("svg", { ...iconProps, children: [_jsx("circle", { cx: "8", cy: "8", r: "4.5" }), _jsx("path", { d: "M8 1v2.5M8 12.5V15M1 8h2.5M12.5 8H15" })] }));
@@ -941,6 +1052,15 @@ const CameraMenuItem = styled.button `
941
1052
  outline: 2px solid var(--kerbcast-accent, #00ff88);
942
1053
  outline-offset: -2px;
943
1054
  }
1055
+
1056
+ &:disabled {
1057
+ cursor: default;
1058
+ color: rgba(255, 255, 255, 0.45);
1059
+ }
1060
+
1061
+ &:disabled:not([aria-checked="true"]) {
1062
+ background: transparent;
1063
+ }
944
1064
  `;
945
1065
  const TopMeta = styled.div `
946
1066
  font-size: 11px;
@@ -1106,7 +1226,26 @@ const Panel = styled.div `
1106
1226
  border-radius: 4px;
1107
1227
  overflow: hidden;
1108
1228
  `;
1229
+ /*
1230
+ * Fills the host and centres the Stage at the camera's configured aspect
1231
+ * (--kc-feed-w / --kc-feed-h), so the picture fills the Stage and the host's
1232
+ * spare room sits outside it.
1233
+ */
1234
+ const FeedBox = styled.div `
1235
+ width: 100%;
1236
+ height: 100%;
1237
+ min-width: 0;
1238
+ min-height: 0;
1239
+ container-type: size;
1240
+ display: flex;
1241
+ align-items: center;
1242
+ justify-content: center;
1243
+ `;
1109
1244
  const Stage = styled(Panel) `
1245
+ width: min(100cqw, 100cqh * var(--kc-feed-w) / var(--kc-feed-h));
1246
+ height: auto;
1247
+ aspect-ratio: var(--kc-feed-w) / var(--kc-feed-h);
1248
+ flex: none;
1110
1249
  padding: 0;
1111
1250
  gap: 0;
1112
1251
  position: relative;
@@ -0,0 +1,110 @@
1
+ import { type EffectiveCommandTrack, type FrameUtSource } from "@ksp-gonogo/kerbcast";
2
+ import { type ReactNode } from "react";
3
+ /** Default colours; each is overridable through the named CSS custom property. */
4
+ export declare const COMMAND_BRACKET_COLOURS: {
5
+ readonly clear: "var(--kerbcast-bracket-clear, #4caf50)";
6
+ readonly occluded: "var(--kerbcast-bracket-occluded, #f44336)";
7
+ };
8
+ export interface ContentRect {
9
+ x: number;
10
+ y: number;
11
+ width: number;
12
+ height: number;
13
+ }
14
+ /**
15
+ * Where `object-fit: contain` media actually lands inside its box. Falls back
16
+ * to the whole box while the media size is unknown.
17
+ */
18
+ export declare function containedContentRect(boxWidth: number, boxHeight: number, mediaWidth: number, mediaHeight: number): ContentRect;
19
+ export interface BracketPoint {
20
+ x: number;
21
+ y: number;
22
+ }
23
+ /** The bracket box, in the pixel space of the bracket SVG (the video element's box). */
24
+ export interface BracketGeometry {
25
+ /** Box centre, which is the aim point. */
26
+ cx: number;
27
+ cy: number;
28
+ /** Half the box side in display px. */
29
+ half: number;
30
+ /** Length of each corner stroke. */
31
+ arm: number;
32
+ stroke: number;
33
+ /** The box's top-left and bottom-right corner points. */
34
+ topLeft: BracketPoint;
35
+ bottomRight: BracketPoint;
36
+ }
37
+ /**
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
+ */
42
+ export declare function bracketGeometry(rect: ContentRect, bracketHalfPx: number | null, frameHeightPx: number): BracketGeometry;
43
+ export type CommandBracketCorner = "topLeft" | "bottomRight";
44
+ export interface CommandBracketCornerStyle {
45
+ colour: string;
46
+ dashed: boolean;
47
+ }
48
+ /** Changes to one default corner; unset fields keep the default. */
49
+ export interface CommandBracketCornerOverride {
50
+ colour?: string;
51
+ dashed?: boolean;
52
+ hidden?: boolean;
53
+ }
54
+ export type CommandBracketCornerOverrides = Partial<Record<CommandBracketCorner, CommandBracketCornerOverride>>;
55
+ /** Everything a bracket overlay needs to line up with the drawn brackets. */
56
+ export interface CommandBracketContext {
57
+ geometry: BracketGeometry;
58
+ /** Size of the bracket SVG, the video element's box. */
59
+ width: number;
60
+ height: number;
61
+ /** Frame UT the brackets describe. */
62
+ displayUt: number;
63
+ utSource: FrameUtSource | "override" | null;
64
+ state: EffectiveCommandTrack;
65
+ }
66
+ /**
67
+ * Consumer additions to a command feed's brackets. Each part is optional and
68
+ * is called on every render with the context for the displayed frame.
69
+ */
70
+ export interface CommandBracketOverlay {
71
+ /**
72
+ * SVG content drawn inside the bracket SVG above the default corners, in
73
+ * the same pixel space. Runs in every state, with or without a fix and
74
+ * over a blanked feed; `ctx.state` says which.
75
+ */
76
+ render?: (ctx: CommandBracketContext) => ReactNode;
77
+ /** Restyle or hide the default corners. */
78
+ corners?: CommandBracketCornerOverrides | ((ctx: CommandBracketContext) => CommandBracketCornerOverrides | null | undefined);
79
+ /**
80
+ * The readout text under the box. Receives the default text, which is
81
+ * empty while the feed is blanked; return a replacement (append to keep
82
+ * it), or null to hide the readout. Runs in every state.
83
+ */
84
+ readout?: (defaultText: string, ctx: CommandBracketContext) => string | null;
85
+ }
86
+ export interface CommandBracketsProps {
87
+ state: EffectiveCommandTrack;
88
+ displayUt: number;
89
+ /** Where `displayUt` came from, mirrored to the readout as `data-ut-source`. */
90
+ utSource?: FrameUtSource | "override" | null;
91
+ video: HTMLVideoElement | null;
92
+ /** Frame aspect to assume until the video reports its own size. */
93
+ fallbackSize?: {
94
+ width: number;
95
+ height: number;
96
+ } | null;
97
+ overlay?: CommandBracketOverlay | null;
98
+ }
99
+ /**
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.
105
+ */
106
+ export declare function CommandBrackets({ state, displayUt, utSource, video, fallbackSize, overlay, }: CommandBracketsProps): import("react").JSX.Element;
107
+ /** Reason a command feed is blanked, centred on black over the feed. */
108
+ export declare function CommandBlank({ reason }: {
109
+ reason: string;
110
+ }): import("react").JSX.Element;