@ksp-gonogo/kerbcast-react 1.10.3 → 1.11.0-rc.4

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,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
15
  import { 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.
@@ -444,6 +464,32 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
444
464
  closeTrackingMenu();
445
465
  }, [client, flightId, trackMode, closeTrackingMenu]);
446
466
  // -------------------------------------------------------------------------
467
+ // Command camera target picker. Lists every craft the game offers; the
468
+ // ineligible ones stay visible but disabled with the game's reason, and
469
+ // the current target follows the published subjectPersistentId. Picking
470
+ // one aims the camera at it. Hidden while a command authority holds the
471
+ // aim lock, since only it can aim then.
472
+ // -------------------------------------------------------------------------
473
+ const commandTargets = commandCamera?.commandCentre?.targets ?? [];
474
+ const commandTargetId = commandCamera?.commandCentre?.subjectPersistentId ?? 0;
475
+ const commandLocked = commandCamera?.commandTrack?.authority != null;
476
+ const commandTargetAvailable = isCommandFeed && !disableManualControls && !commandLocked && commandTargets.length > 0;
477
+ const commandTargetMenuId = useId();
478
+ const commandTargetMenu = usePortalMenu({
479
+ maxWidth: MENU_MAX_WIDTH,
480
+ maxHeight: MENU_MAX_HEIGHT,
481
+ align: "end",
482
+ });
483
+ const closeCommandTargetMenu = commandTargetMenu.close;
484
+ const selectCommandTarget = useCallback((persistentId) => {
485
+ if (flightId === null)
486
+ return;
487
+ client
488
+ .setCommandAim(flightId, { kind: CommandAimKind.Vessel, persistentId })
489
+ .catch(() => { });
490
+ closeCommandTargetMenu();
491
+ }, [client, flightId, closeCommandTargetMenu]);
492
+ // -------------------------------------------------------------------------
447
493
  // REC control (opt-in). A stateful toggle that starts/stops a client-side
448
494
  // recording of this feed via the recordings store; state is read from the
449
495
  // store (isRecording), never held locally, so it stays correct if the
@@ -514,6 +560,11 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
514
560
  const pipLabel = isPip ? "Exit picture in picture" : "Picture in picture";
515
561
  const fullscreenLabel = isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
516
562
  const recordingLabel = recordingActive ? "Stop recording" : "Start recording";
563
+ const bracketsLabel = "Target brackets";
564
+ const commandTargetName = commandTargets.find((t) => t.persistentId === commandTargetId)?.vesselName;
565
+ const commandTargetLabel = commandTargetName
566
+ ? `Watching ${commandTargetName}`
567
+ : "Choose target";
517
568
  const entries = [
518
569
  ...(actions?.map(feedActionToEntry) ?? []),
519
570
  ...(qualityAvailable
@@ -521,7 +572,8 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
521
572
  {
522
573
  id: "quality",
523
574
  label: "Quality",
524
- stateful: false,
575
+ /* Stays inline: its menu anchors on this button, which the overflow menu unmounts on click. */
576
+ stateful: true,
525
577
  render: () => (_jsxs(OverlayIconButton, { ref: qualityMenu.triggerRef, type: "button", "aria-label": "Quality", "aria-haspopup": "menu", "aria-expanded": qualityMenu.open, "aria-controls": qualityMenuId, title: qualityThrottled
526
578
  ? "Quality (throttled by adaptive performance)"
527
579
  : "Quality", "$active": qualityMenu.open, onClick: qualityMenu.toggle, children: [_jsx(QualityIcon, {}), qualityThrottled && _jsx(ThrottledDot, { "aria-hidden": "true" })] })),
@@ -538,6 +590,27 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
538
590
  },
539
591
  ]
540
592
  : []),
593
+ ...(targetBracketsAvailable
594
+ ? [
595
+ {
596
+ id: "target-brackets",
597
+ label: bracketsLabel,
598
+ stateful: true,
599
+ render: () => (_jsx(OverlayIconButton, { type: "button", "aria-label": bracketsLabel, "aria-pressed": bracketsOn, title: bracketsLabel, "$active": bracketsOn, onClick: toggleBrackets, children: _jsx(TargetBracketsIcon, {}) })),
600
+ },
601
+ ]
602
+ : []),
603
+ ...(commandTargetAvailable
604
+ ? [
605
+ {
606
+ id: "command-target",
607
+ label: commandTargetLabel,
608
+ /* Stays inline: its menu anchors on this button, which the overflow menu unmounts on click. */
609
+ stateful: true,
610
+ 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, {}) })),
611
+ },
612
+ ]
613
+ : []),
541
614
  ...(trackingAvailable
542
615
  ? [
543
616
  {
@@ -589,6 +662,8 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
589
662
  fullscreenAvailable ||
590
663
  qualityAvailable ||
591
664
  trackingAvailable ||
665
+ targetBracketsAvailable ||
666
+ commandTargetAvailable ||
592
667
  enableRecording);
593
668
  const hasActionBar = showActions &&
594
669
  (hasCameras ||
@@ -597,7 +672,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
597
672
  builtInActions);
598
673
  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
674
  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 &&
675
+ 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: attachVideo, autoPlay: true, playsInline: true, muted: true, controls: false, onClick: () => setChromePinned((v) => !v) }), topOverlay, actionBar, qualityAvailable &&
601
676
  qualityMenu.open &&
602
677
  qualityMenu.position &&
603
678
  camera &&
@@ -605,7 +680,16 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
605
680
  trackingMenu.open &&
606
681
  trackingMenu.position &&
607
682
  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) => {
683
+ 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 &&
684
+ commandTargetMenu.open &&
685
+ commandTargetMenu.position &&
686
+ createPortal(_jsx(CameraMenu, { ref: commandTargetMenu.menuRef, id: commandTargetMenuId, role: "menu", "aria-label": "Target", style: commandTargetMenu.position, children: commandTargets.map((t) => {
687
+ const eligible = t.eligibility === CommandTargetEligibility.Ok;
688
+ const current = t.persistentId === commandTargetId;
689
+ 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));
690
+ }) }), document.body), commandState &&
691
+ !outOfFlight &&
692
+ (_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) => {
609
693
  if (e.detail === 0)
610
694
  controllerRef.current?.nudgeZoom(-1);
611
695
  }, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
@@ -665,6 +749,14 @@ function FullscreenExitIcon() {
665
749
  function QualityIcon() {
666
750
  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
751
  }
752
+ /* Target brackets: the top-left and bottom-right corners around a centre dot. */
753
+ function TargetBracketsIcon() {
754
+ 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" })] }));
755
+ }
756
+ /* Command camera target: a craft silhouette inside a selection ring. */
757
+ function CommandTargetIcon() {
758
+ 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" })] }));
759
+ }
668
760
  /* Auto-track: a crosshair / reticle. */
669
761
  function CrosshairIcon() {
670
762
  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 +1033,15 @@ const CameraMenuItem = styled.button `
941
1033
  outline: 2px solid var(--kerbcast-accent, #00ff88);
942
1034
  outline-offset: -2px;
943
1035
  }
1036
+
1037
+ &:disabled {
1038
+ cursor: default;
1039
+ color: rgba(255, 255, 255, 0.45);
1040
+ }
1041
+
1042
+ &:disabled:not([aria-checked="true"]) {
1043
+ background: transparent;
1044
+ }
944
1045
  `;
945
1046
  const TopMeta = styled.div `
946
1047
  font-size: 11px;
@@ -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;
@@ -0,0 +1,167 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { commandReadout, } from "@ksp-gonogo/kerbcast";
3
+ import { useEffect, useState } from "react";
4
+ import styled from "styled-components";
5
+ const BRACKET_STROKE_PX = 2;
6
+ const BRACKET_MIN_HALF_PX = 14;
7
+ const BRACKET_ARM_FRACTION = 0.3;
8
+ const BRACKET_MIN_ARM_PX = 6;
9
+ const READOUT_GAP_PX = 11;
10
+ /** Default colours; each is overridable through the named CSS custom property. */
11
+ export const COMMAND_BRACKET_COLOURS = {
12
+ clear: "var(--kerbcast-bracket-clear, #4caf50)",
13
+ occluded: "var(--kerbcast-bracket-occluded, #f44336)",
14
+ };
15
+ /**
16
+ * Where `object-fit: contain` media actually lands inside its box. Falls back
17
+ * to the whole box while the media size is unknown.
18
+ */
19
+ export function containedContentRect(boxWidth, boxHeight, mediaWidth, mediaHeight) {
20
+ if (mediaWidth <= 0 || mediaHeight <= 0 || boxWidth <= 0 || boxHeight <= 0) {
21
+ return { x: 0, y: 0, width: Math.max(0, boxWidth), height: Math.max(0, boxHeight) };
22
+ }
23
+ const scale = Math.min(boxWidth / mediaWidth, boxHeight / mediaHeight);
24
+ const width = mediaWidth * scale;
25
+ const height = mediaHeight * scale;
26
+ return { x: (boxWidth - width) / 2, y: (boxHeight - height) / 2, width, height };
27
+ }
28
+ /**
29
+ * The bracket box centred on the displayed frame. `bracketHalfPx` is in
30
+ * capture px of a `frameHeightPx`-tall frame, so it scales by the displayed
31
+ * height; a missing half-size draws the minimum box.
32
+ */
33
+ export function bracketGeometry(rect, bracketHalfPx, frameHeightPx) {
34
+ const scale = frameHeightPx > 0 ? rect.height / frameHeightPx : 1;
35
+ const half = Math.max(BRACKET_MIN_HALF_PX, (bracketHalfPx ?? 0) * scale);
36
+ const arm = Math.max(BRACKET_MIN_ARM_PX, BRACKET_ARM_FRACTION * half * 2);
37
+ const cx = rect.x + rect.width / 2;
38
+ const cy = rect.y + rect.height / 2;
39
+ return {
40
+ cx,
41
+ cy,
42
+ half,
43
+ arm,
44
+ stroke: BRACKET_STROKE_PX,
45
+ topLeft: { x: cx - half, y: cy - half },
46
+ bottomRight: { x: cx + half, y: cy + half },
47
+ };
48
+ }
49
+ function cameraCornerStyle(state) {
50
+ return state.occluded
51
+ ? { colour: COMMAND_BRACKET_COLOURS.occluded, dashed: true }
52
+ : { colour: COMMAND_BRACKET_COLOURS.clear, dashed: false };
53
+ }
54
+ function applyCorner(base, override) {
55
+ return {
56
+ colour: override?.colour ?? base.colour,
57
+ dashed: override?.dashed ?? base.dashed,
58
+ hidden: override?.hidden ?? false,
59
+ };
60
+ }
61
+ /** Tracks the rendered box and intrinsic size of a video element. */
62
+ function useVideoBox(video) {
63
+ const [box, setBox] = useState({ boxW: 0, boxH: 0, mediaW: 0, mediaH: 0 });
64
+ useEffect(() => {
65
+ if (!video)
66
+ return;
67
+ const measure = () => {
68
+ const next = {
69
+ boxW: video.clientWidth,
70
+ boxH: video.clientHeight,
71
+ mediaW: video.videoWidth,
72
+ mediaH: video.videoHeight,
73
+ };
74
+ setBox((prev) => prev.boxW === next.boxW &&
75
+ prev.boxH === next.boxH &&
76
+ prev.mediaW === next.mediaW &&
77
+ prev.mediaH === next.mediaH
78
+ ? prev
79
+ : next);
80
+ };
81
+ measure();
82
+ const observer = new ResizeObserver(measure);
83
+ observer.observe(video);
84
+ video.addEventListener("loadedmetadata", measure);
85
+ video.addEventListener("resize", measure);
86
+ return () => {
87
+ observer.disconnect();
88
+ video.removeEventListener("loadedmetadata", measure);
89
+ video.removeEventListener("resize", measure);
90
+ };
91
+ }, [video]);
92
+ return box;
93
+ }
94
+ /**
95
+ * Target brackets for a command camera feed: the aim point sits at frame
96
+ * centre, so the box is always centred on the displayed video. Both corners
97
+ * show the camera axis by default; an overlay can restyle either and draw
98
+ * its own marks. Without a fix, or over a blanked feed, the default corners
99
+ * are left out but the overlay and readout still run.
100
+ */
101
+ export function CommandBrackets({ state, displayUt, utSource, video, fallbackSize, overlay, }) {
102
+ const { boxW, boxH, mediaW, mediaH } = useVideoBox(video);
103
+ const rect = containedContentRect(boxW, boxH, mediaW || fallbackSize?.width || 0, mediaH || fallbackSize?.height || 0);
104
+ const g = bracketGeometry(rect, state.bracketHalfPx, state.frameHeightPx || mediaH);
105
+ const ctx = {
106
+ geometry: g,
107
+ width: boxW,
108
+ height: boxH,
109
+ displayUt,
110
+ utSource: utSource ?? null,
111
+ state,
112
+ };
113
+ const overrides = typeof overlay?.corners === "function" ? overlay.corners(ctx) : overlay?.corners;
114
+ const camera = cameraCornerStyle(state);
115
+ const topLeft = applyCorner(camera, overrides?.topLeft);
116
+ const bottomRight = applyCorner(camera, overrides?.bottomRight);
117
+ const { x: left, y: top } = g.topLeft;
118
+ const { x: right, y: bottom } = g.bottomRight;
119
+ const blanked = !!state.blank;
120
+ const drawCorners = state.hasFix && !blanked;
121
+ const defaultReadout = blanked ? "" : commandReadout(state);
122
+ const readout = overlay?.readout ? overlay.readout(defaultReadout, ctx) : defaultReadout;
123
+ return (_jsxs(_Fragment, { children: [(drawCorners || overlay?.render) && (_jsxs(BracketSvg, { "aria-hidden": "true", "data-camera-axis": state.camera, width: boxW, height: boxH, children: [drawCorners && !topLeft.hidden && (_jsx("path", { "data-corner": "topLeft", d: `M${left} ${top + g.arm}L${left} ${top}L${left + g.arm} ${top}`, fill: "none", strokeWidth: g.stroke, strokeDasharray: topLeft.dashed ? "3 2" : undefined, style: { stroke: topLeft.colour } })), drawCorners && !bottomRight.hidden && (_jsx("path", { "data-corner": "bottomRight", d: `M${right} ${bottom - g.arm}L${right} ${bottom}L${right - g.arm} ${bottom}`, fill: "none", strokeWidth: g.stroke, strokeDasharray: bottomRight.dashed ? "3 2" : undefined, style: { stroke: bottomRight.colour } })), overlay?.render && _jsx("g", { "data-bracket-overlay": "", children: overlay.render(ctx) })] })), readout && (_jsx(Readout, { "data-ut-source": utSource ?? undefined, style: { left: g.cx, top: bottom + READOUT_GAP_PX }, children: readout }))] }));
124
+ }
125
+ /** Reason a command feed is blanked, centred on black over the feed. */
126
+ export function CommandBlank({ reason }) {
127
+ return (_jsx(BlankOverlay, { role: "status", "aria-label": "Feed blanked", children: _jsx(BlankText, { children: reason }) }));
128
+ }
129
+ const BracketSvg = styled.svg `
130
+ position: absolute;
131
+ top: 0;
132
+ left: 0;
133
+ z-index: 1;
134
+ overflow: visible;
135
+ pointer-events: none;
136
+ `;
137
+ const Readout = styled.div `
138
+ position: absolute;
139
+ z-index: 1;
140
+ transform: translateX(-50%);
141
+ white-space: nowrap;
142
+ color: #fff;
143
+ font-size: 10px;
144
+ font-weight: 600;
145
+ letter-spacing: 0.12em;
146
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
147
+ pointer-events: none;
148
+ `;
149
+ const BlankOverlay = styled.div `
150
+ position: absolute;
151
+ inset: 0;
152
+ z-index: 1;
153
+ display: flex;
154
+ align-items: center;
155
+ justify-content: center;
156
+ padding: 1rem;
157
+ background: #000;
158
+ pointer-events: none;
159
+ `;
160
+ const BlankText = styled.span `
161
+ color: rgba(255, 255, 255, 0.85);
162
+ font-size: 0.85rem;
163
+ font-weight: 600;
164
+ letter-spacing: 0.12em;
165
+ text-align: center;
166
+ text-transform: uppercase;
167
+ `;
@@ -0,0 +1,22 @@
1
+ import { type FrameUtSource, type CommandTrackEvent } from "@ksp-gonogo/kerbcast";
2
+ export { MAX_UT_EXTRAPOLATION_MS, extrapolateCaptureUt } from "@ksp-gonogo/kerbcast";
3
+ /** Real milliseconds until the first event after `displayUt`, or `null` when none is ahead. */
4
+ export declare function msUntilNextEvent(events: readonly CommandTrackEvent[] | undefined, displayUt: number, warpRate: number): number | null;
5
+ /** Whether an event's UT lies in `(from, to]`, i.e. moving the display from `from` to `to` changes the state in force. */
6
+ export declare function crossesEvent(events: readonly CommandTrackEvent[] | undefined, from: number, to: number): boolean;
7
+ /** The UT a command feed is showing and where it came from; `"override"` is a host's own value. */
8
+ export interface CommandDisplayUt {
9
+ ut: number;
10
+ source: FrameUtSource | "override";
11
+ }
12
+ /**
13
+ * The UT a command feed is showing. An explicit `override` (a host with its
14
+ * own delayed playout) wins. Otherwise, given the feed's `flightId` and the
15
+ * `video` element showing it, the presented frame's own capture stamp is
16
+ * used (`source: "exact"`); without one, the client's capture clock is
17
+ * interpolated to now (`"estimated"`). Re-evaluated twice a second while
18
+ * `enabled` so readout ages keep counting, and again the moment the
19
+ * displayed UT reaches the next of `events`, so a state change lands on its
20
+ * frame rather than on the next tick. `null` when nothing is known.
21
+ */
22
+ export declare function useCommandDisplayUt(enabled: boolean, override?: number, events?: readonly CommandTrackEvent[], flightId?: number | null, video?: HTMLVideoElement | null): CommandDisplayUt | null;
@@ -0,0 +1,104 @@
1
+ import { useEffect, useReducer, useRef } from "react";
2
+ import { extrapolateCaptureUt, } from "@ksp-gonogo/kerbcast";
3
+ import { useKerbcastClient } from "../context";
4
+ import { nowMs } from "../timing";
5
+ export { MAX_UT_EXTRAPOLATION_MS, extrapolateCaptureUt } from "@ksp-gonogo/kerbcast";
6
+ const TICK_MS = 500;
7
+ /** Real milliseconds until the first event after `displayUt`, or `null` when none is ahead. */
8
+ export function msUntilNextEvent(events, displayUt, warpRate) {
9
+ if (!events?.length || !(warpRate > 0))
10
+ return null;
11
+ let next = Infinity;
12
+ for (const e of events)
13
+ if (e.ut > displayUt && e.ut < next)
14
+ next = e.ut;
15
+ return next === Infinity ? null : ((next - displayUt) / warpRate) * 1000;
16
+ }
17
+ /** Whether an event's UT lies in `(from, to]`, i.e. moving the display from `from` to `to` changes the state in force. */
18
+ export function crossesEvent(events, from, to) {
19
+ if (!events?.length)
20
+ return false;
21
+ const lo = Math.min(from, to);
22
+ const hi = Math.max(from, to);
23
+ return events.some((e) => e.ut > lo && e.ut <= hi);
24
+ }
25
+ /**
26
+ * The UT a command feed is showing. An explicit `override` (a host with its
27
+ * own delayed playout) wins. Otherwise, given the feed's `flightId` and the
28
+ * `video` element showing it, the presented frame's own capture stamp is
29
+ * used (`source: "exact"`); without one, the client's capture clock is
30
+ * interpolated to now (`"estimated"`). Re-evaluated twice a second while
31
+ * `enabled` so readout ages keep counting, and again the moment the
32
+ * displayed UT reaches the next of `events`, so a state change lands on its
33
+ * frame rather than on the next tick. `null` when nothing is known.
34
+ */
35
+ export function useCommandDisplayUt(enabled, override, events, flightId, video) {
36
+ const client = useKerbcastClient();
37
+ const sample = useRef({
38
+ captureUt: client.clock.captureUt,
39
+ warpRate: client.clock.warpRate,
40
+ at: nowMs(),
41
+ });
42
+ const frame = useRef(null);
43
+ const shown = useRef(null);
44
+ const eventsRef = useRef(events);
45
+ eventsRef.current = events;
46
+ const [, tick] = useReducer((c) => c + 1, 0);
47
+ useEffect(() => {
48
+ const take = () => {
49
+ const { captureUt, warpRate } = client.clock;
50
+ sample.current = { captureUt, warpRate, at: nowMs() };
51
+ };
52
+ take();
53
+ return client.on("settings-change", take);
54
+ }, [client]);
55
+ useEffect(() => {
56
+ if (!enabled)
57
+ return;
58
+ const interval = setInterval(tick, TICK_MS);
59
+ return () => clearInterval(interval);
60
+ }, [enabled]);
61
+ /* Exact frames re-render only when they change what is drawn: a switch of
62
+ source, or a step across an event. Ages catch up on the tick. */
63
+ const overriding = override !== undefined;
64
+ useEffect(() => {
65
+ frame.current = null;
66
+ if (!enabled || overriding || flightId == null || !video)
67
+ return;
68
+ return client.watchDisplayedFrames(flightId, video, (f) => {
69
+ frame.current = f.source === "exact" ? f : null;
70
+ const prev = shown.current;
71
+ if (!frame.current) {
72
+ if (prev?.source === "exact")
73
+ tick();
74
+ return;
75
+ }
76
+ if (prev === null ||
77
+ prev.source !== "exact" ||
78
+ crossesEvent(eventsRef.current, prev.ut, f.ut)) {
79
+ tick();
80
+ }
81
+ });
82
+ }, [client, enabled, overriding, flightId, video]);
83
+ const { captureUt, warpRate, at } = sample.current;
84
+ const exact = frame.current;
85
+ const displayUt = overriding
86
+ ? { ut: override, source: "override" }
87
+ : exact
88
+ ? { ut: exact.ut, source: "exact" }
89
+ : captureUt === null
90
+ ? null
91
+ : { ut: extrapolateCaptureUt(captureUt, warpRate, at, nowMs()), source: "estimated" };
92
+ shown.current = displayUt;
93
+ const untilEdge = enabled && displayUt?.source === "estimated"
94
+ ? msUntilNextEvent(events, displayUt.ut, warpRate)
95
+ : null;
96
+ /* Edges further out than a tick are caught by a later render's timer. */
97
+ useEffect(() => {
98
+ if (untilEdge === null || untilEdge > TICK_MS)
99
+ return;
100
+ const timer = setTimeout(tick, Math.max(0, untilEdge) + 1);
101
+ return () => clearTimeout(timer);
102
+ });
103
+ return displayUt;
104
+ }