@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 +19 -4
- package/dist/CameraFeed.d.ts +21 -0
- package/dist/CameraFeed.js +172 -33
- package/dist/CommandBrackets.d.ts +110 -0
- package/dist/CommandBrackets.js +167 -0
- package/dist/KerbalFaceFeed.js +2 -2
- package/dist/hooks/useCommandDisplayUt.d.ts +22 -0
- package/dist/hooks/useCommandDisplayUt.js +104 -0
- package/dist/hooks/useDisplayedFrameUt.d.ts +11 -0
- package/dist/hooks/useDisplayedFrameUt.js +22 -0
- package/dist/hooks/useReportDisplaySize.d.ts +49 -3
- package/dist/hooks/useReportDisplaySize.js +187 -21
- package/dist/hooks/useTargetBracketsPreference.d.ts +6 -0
- package/dist/hooks/useTargetBracketsPreference.js +39 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +538 -8
- package/src/CameraFeed.tsx +520 -242
- package/src/CommandBrackets.test.tsx +259 -0
- package/src/CommandBrackets.tsx +341 -0
- package/src/KerbalFaceFeed.tsx +2 -2
- package/src/hooks/useCommandDisplayUt.test.tsx +156 -0
- package/src/hooks/useCommandDisplayUt.ts +135 -0
- package/src/hooks/useDisplayedFrameUt.ts +26 -0
- package/src/hooks/useReportDisplaySize.loop.test.tsx +290 -0
- package/src/hooks/useReportDisplaySize.test.tsx +18 -9
- package/src/hooks/useReportDisplaySize.ts +232 -23
- package/src/hooks/useTargetBracketsPreference.ts +38 -0
- package/src/index.test.ts +2 -1
- package/src/index.ts +15 -0
package/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
|
|
93
|
-
client's per-consumer `reportDisplaySize` (auto-resolution). The
|
|
94
|
-
primitives use it internally; multiple views of one camera from a
|
|
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.
|
package/dist/CameraFeed.d.ts
CHANGED
|
@@ -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;
|
package/dist/CameraFeed.js
CHANGED
|
@@ -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.
|
|
375
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
controllerRef.current?.
|
|
623
|
-
|
|
624
|
-
|
|
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;
|