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