@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/src/CameraFeed.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CameraState, KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
1
|
+
import type { Anchor, CameraState, DisplayedFrameUt, KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
2
2
|
import {
|
|
3
3
|
CameraKind,
|
|
4
4
|
CommandAimKind,
|
|
@@ -32,17 +32,20 @@ import {
|
|
|
32
32
|
type FeedAction,
|
|
33
33
|
type FeedActionBarEntry,
|
|
34
34
|
} from "./FeedActionBar";
|
|
35
|
-
import { CommandBlank,
|
|
35
|
+
import { CommandBlank, LiveCommandBrackets, type CommandBracketOverlay } from "./CommandBrackets";
|
|
36
|
+
import { LiveAnchorMarkers, type AnchorOverlay } from "./AnchorMarkers";
|
|
36
37
|
import { useCommandDisplayUt } from "./hooks/useCommandDisplayUt";
|
|
37
38
|
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
38
39
|
import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
39
40
|
import { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
41
|
+
import { useKerbcastWaitingForSlot } from "./hooks/useKerbcastWaitingForSlot";
|
|
40
42
|
import { useRecordings } from "./hooks/useRecordings";
|
|
41
43
|
import { DEFAULT_FEED_ASPECT, useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
42
44
|
import { useTargetBracketsPreference } from "./hooks/useTargetBracketsPreference";
|
|
43
45
|
import { StandbyIcon } from "./StandbyIcon";
|
|
44
46
|
import { isCameraDestroyed } from "./lifecycle";
|
|
45
|
-
import {
|
|
47
|
+
import { claimAnchors } from "./sharedAnchors";
|
|
48
|
+
import { menuPortalRoot, usePortalMenu } from "./menuPositioning";
|
|
46
49
|
import { formatElapsed, nowMs } from "./timing";
|
|
47
50
|
|
|
48
51
|
// ---------------------------------------------------------------------------
|
|
@@ -75,6 +78,16 @@ function presetDim(operatorDim: number, scale: number): number {
|
|
|
75
78
|
return v < 2 ? 2 : v;
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
function videoTrackId(source: MediaProvider | null): string | undefined {
|
|
82
|
+
if (!source || !("getVideoTracks" in source)) return undefined;
|
|
83
|
+
return source.getVideoTracks()[0]?.id;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sameVideoTrack(a: MediaProvider | null, b: MediaStream | null): boolean {
|
|
87
|
+
const id = videoTrackId(a);
|
|
88
|
+
return id !== undefined && id === videoTrackId(b);
|
|
89
|
+
}
|
|
90
|
+
|
|
78
91
|
/*
|
|
79
92
|
* Fullscreen helpers. Safari (incl. iPadOS) only exposes the webkit-prefixed
|
|
80
93
|
* API; iOS iPhone has no element fullscreen at all, which `isFullscreenSupported`
|
|
@@ -265,8 +278,9 @@ export interface CameraFeedProps {
|
|
|
265
278
|
enableRecording?: boolean;
|
|
266
279
|
/**
|
|
267
280
|
* Record this feed at the operator's full render size for the duration of
|
|
268
|
-
* the built-in REC control's recording (overrides
|
|
269
|
-
* demand
|
|
281
|
+
* the built-in REC control's recording (overrides the display-size
|
|
282
|
+
* demand and any viewer quality preset; still capped by an operator
|
|
283
|
+
* render-size cap and the adaptive shed). Off by default.
|
|
270
284
|
* Threaded straight into `start(flightId, { forceFullResolution })`; has
|
|
271
285
|
* no effect unless `enableRecording` is also set. While the feed is armed
|
|
272
286
|
* (force sent, waiting for the resolution bump) the tile shows an
|
|
@@ -314,14 +328,53 @@ export interface CameraFeedProps {
|
|
|
314
328
|
* brackets. See {@link CommandBracketOverlay}.
|
|
315
329
|
*/
|
|
316
330
|
targetBracketOverlay?: CommandBracketOverlay;
|
|
331
|
+
/**
|
|
332
|
+
* What a command camera feed says while the camera has no subject to aim
|
|
333
|
+
* at yet and shows its placeholder framing (a launch site camera on its
|
|
334
|
+
* pad, a station looking at the horizon). Drawn over the picture, never
|
|
335
|
+
* into the video, so recordings stay clean. Default "Waiting for target";
|
|
336
|
+
* `null` or an empty string shows nothing.
|
|
337
|
+
*/
|
|
338
|
+
waitingForTargetLabel?: string | null;
|
|
339
|
+
/**
|
|
340
|
+
* What the feed says while its camera has no video slot on this connection
|
|
341
|
+
* (`KerbcastCameraHandle.waitingForSlot`): the pool is at its ceiling or
|
|
342
|
+
* cannot grow. The feed takes the next slot another feed gives up. Drawn
|
|
343
|
+
* over the picture; `null` or an empty string shows nothing.
|
|
344
|
+
*/
|
|
345
|
+
noSlotLabel?: string | null;
|
|
317
346
|
/**
|
|
318
347
|
* Mission UT the displayed video was captured at, for a host running its
|
|
319
348
|
* own delayed playout. Picks which command-track transition is in force and
|
|
320
349
|
* ages the readout. Defaults to the displayed frame's own capture UT where
|
|
321
350
|
* the browser reports frame RTP timestamps, else the client's capture
|
|
322
|
-
* clock interpolated to now.
|
|
351
|
+
* clock interpolated to now. While a command lock is held the brackets and
|
|
352
|
+
* readout come from the displayed frame's own state, which a UT alone does
|
|
353
|
+
* not carry: such a host passes `displayFrame` instead.
|
|
323
354
|
*/
|
|
324
355
|
displayUt?: number;
|
|
356
|
+
/**
|
|
357
|
+
* The frame a host's own delayed playout is showing, as
|
|
358
|
+
* `watchDisplayedFrames` reported it when it arrived. Wins over
|
|
359
|
+
* `displayUt`: its `ut` picks the transition in force and its `command`
|
|
360
|
+
* draws the brackets and readout, so they never run ahead of the picture.
|
|
361
|
+
*/
|
|
362
|
+
displayFrame?: DisplayedFrameUt | null;
|
|
363
|
+
/**
|
|
364
|
+
* Things to mark on this feed's picture: parts, craft, points on a body or
|
|
365
|
+
* directions. Set for the displayed camera as this client's anchors and
|
|
366
|
+
* cleared when the feed shows another camera or unmounts; the game
|
|
367
|
+
* projects each through the camera that rendered every frame, and a
|
|
368
|
+
* default marker is drawn where it lands. One set per camera per client:
|
|
369
|
+
* two feeds on the same camera share it, the last to set winning. At
|
|
370
|
+
* most 16. Pass a stable or memoised array; it is compared by value.
|
|
371
|
+
*/
|
|
372
|
+
anchors?: Anchor[];
|
|
373
|
+
/**
|
|
374
|
+
* Restyle the anchor markers, add SVG of your own, put text under them,
|
|
375
|
+
* or draw anchors set in game too. See {@link AnchorOverlay}.
|
|
376
|
+
*/
|
|
377
|
+
anchorOverlay?: AnchorOverlay;
|
|
325
378
|
}
|
|
326
379
|
|
|
327
380
|
export interface CameraFeedHandle {
|
|
@@ -362,7 +415,12 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
362
415
|
useStream,
|
|
363
416
|
enableTargetBrackets = true,
|
|
364
417
|
targetBracketOverlay,
|
|
418
|
+
waitingForTargetLabel = "Waiting for target",
|
|
419
|
+
noSlotLabel = "Not streaming: too many feeds are open. Close a feed to watch this one.",
|
|
365
420
|
displayUt: displayUtProp,
|
|
421
|
+
displayFrame,
|
|
422
|
+
anchors,
|
|
423
|
+
anchorOverlay,
|
|
366
424
|
},
|
|
367
425
|
ref,
|
|
368
426
|
) {
|
|
@@ -402,7 +460,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
402
460
|
const effectiveShowStatic = showStatic === undefined ? !reducedMotion : showStatic;
|
|
403
461
|
|
|
404
462
|
// -------------------------------------------------------------------------
|
|
405
|
-
// Selection model
|
|
463
|
+
// Selection model
|
|
406
464
|
//
|
|
407
465
|
// Resolution order:
|
|
408
466
|
// 1. Explicit pick, if still present in the list (destroyed or not).
|
|
@@ -472,10 +530,15 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
472
530
|
}, []);
|
|
473
531
|
// The feed frame; fullscreen targets this and ResizeObserver measures it.
|
|
474
532
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
533
|
+
/* Clearing to null on a switch to a camera with no stream yet stops the
|
|
534
|
+
previous camera's last frame lingering. A rebind can hand over a new
|
|
535
|
+
MediaStream around the same track; reassigning that would reset the
|
|
536
|
+
element and flash black, so it is skipped. */
|
|
475
537
|
useEffect(() => {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
538
|
+
const video = videoRef.current;
|
|
539
|
+
if (!video || video.srcObject === stream) return;
|
|
540
|
+
if (sameVideoTrack(video.srcObject, stream)) return;
|
|
541
|
+
video.srcObject = stream;
|
|
479
542
|
}, [stream]);
|
|
480
543
|
|
|
481
544
|
// -------------------------------------------------------------------------
|
|
@@ -499,6 +562,20 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
499
562
|
return cam.on("stall", setIsStale);
|
|
500
563
|
}, [client, flightId]);
|
|
501
564
|
|
|
565
|
+
const [hasSource, setHasSource] = useState(false);
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
if (flightId === null) {
|
|
568
|
+
setHasSource(false);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const cam = client.camera(flightId);
|
|
572
|
+
setHasSource(cam.hasSource);
|
|
573
|
+
return cam.on("source", setHasSource);
|
|
574
|
+
}, [client, flightId]);
|
|
575
|
+
/* The in-stream static keeps the video element presenting frames, so a
|
|
576
|
+
frame count cannot tell a picture from static; this attribute can. */
|
|
577
|
+
const signal = flightId === null ? undefined : !hasSource ? "none" : isStale ? "stalled" : "live";
|
|
578
|
+
|
|
502
579
|
// -------------------------------------------------------------------------
|
|
503
580
|
// Fullscreen + Picture-in-Picture (opt-in, feature-detected)
|
|
504
581
|
// -------------------------------------------------------------------------
|
|
@@ -540,6 +617,23 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
540
617
|
};
|
|
541
618
|
}, [pipAvailable, flightId]);
|
|
542
619
|
|
|
620
|
+
/* Browsers usually consume Escape to leave fullscreen themselves; this
|
|
621
|
+
covers hosts and embedded views that hand the key to the page instead. */
|
|
622
|
+
useEffect(() => {
|
|
623
|
+
if (!isFullscreen) return;
|
|
624
|
+
const onKey = (e: KeyboardEvent) => {
|
|
625
|
+
if (
|
|
626
|
+
e.key === "Escape" &&
|
|
627
|
+
!e.defaultPrevented &&
|
|
628
|
+
currentFullscreenElement() === wrapRef.current
|
|
629
|
+
) {
|
|
630
|
+
exitFullscreen();
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
document.addEventListener("keydown", onKey);
|
|
634
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
635
|
+
}, [isFullscreen]);
|
|
636
|
+
|
|
543
637
|
const toggleFullscreen = useCallback(() => {
|
|
544
638
|
const el = wrapRef.current;
|
|
545
639
|
if (!el) return;
|
|
@@ -558,6 +652,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
558
652
|
}, []);
|
|
559
653
|
|
|
560
654
|
const isDestroyed = camera ? isCameraDestroyed(camera) : false;
|
|
655
|
+
const waitingForSlot = useKerbcastWaitingForSlot(flightId);
|
|
561
656
|
/* Gating here rather than at the render sites covers the whole manual
|
|
562
657
|
path at once: the controls unmount, the effects below zero any in-flight
|
|
563
658
|
rate, and the imperative handle's pan/zoom methods no-op. */
|
|
@@ -582,16 +677,29 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
582
677
|
const commandTrack = commandCamera?.commandTrack ?? null;
|
|
583
678
|
const commandDisplayUt = useCommandDisplayUt(
|
|
584
679
|
commandTrack !== null,
|
|
585
|
-
displayUtProp,
|
|
680
|
+
displayFrame ? displayFrame.ut : displayUtProp,
|
|
586
681
|
commandTrack?.events,
|
|
587
682
|
commandCamera?.flightId ?? null,
|
|
588
683
|
videoEl,
|
|
684
|
+
displayFrame?.command,
|
|
589
685
|
);
|
|
590
686
|
const commandState = commandTrack
|
|
591
|
-
? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut)
|
|
687
|
+
? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut, commandDisplayUt?.command)
|
|
592
688
|
: null;
|
|
689
|
+
/* This feed's anchors go to the camera it displays, and leave with it. */
|
|
690
|
+
const anchorsKey = anchors?.length ? JSON.stringify(anchors) : "";
|
|
691
|
+
useEffect(() => {
|
|
692
|
+
if (flightId === null || !anchorsKey) return;
|
|
693
|
+
return claimAnchors(client, flightId, JSON.parse(anchorsKey) as Anchor[]);
|
|
694
|
+
}, [client, flightId, anchorsKey]);
|
|
695
|
+
const drawsAnchors = !!anchorsKey || !!anchorOverlay?.owners?.length;
|
|
696
|
+
|
|
593
697
|
const [bracketsOn, toggleBrackets] = useTargetBracketsPreference(
|
|
594
|
-
commandCamera
|
|
698
|
+
commandCamera
|
|
699
|
+
? (commandCamera.commandCentre?.siteKey || commandCamera.commandCentre?.nodeName || String(commandCamera.flightId))
|
|
700
|
+
: null,
|
|
701
|
+
// 1.11 keyed the preference by stationId; carry a saved choice over.
|
|
702
|
+
commandCamera?.commandCentre?.stationId ?? null,
|
|
595
703
|
);
|
|
596
704
|
const targetBracketsAvailable = enableTargetBrackets && isCommandFeed;
|
|
597
705
|
|
|
@@ -861,8 +969,12 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
861
969
|
// one aims the camera at it. Hidden while a command authority holds the
|
|
862
970
|
// aim lock, since only it can aim then.
|
|
863
971
|
// -------------------------------------------------------------------------
|
|
864
|
-
|
|
865
|
-
const
|
|
972
|
+
/* The list as it stood at the displayed frame, so a delayed feed never offers what its picture has not reached. */
|
|
973
|
+
const shownCentre = commandCamera
|
|
974
|
+
? client.commandCentreAt(commandCamera.flightId, commandDisplayUt?.ut)
|
|
975
|
+
: null;
|
|
976
|
+
const commandTargets = shownCentre?.targets ?? [];
|
|
977
|
+
const commandTargetId = shownCentre?.subjectPersistentId ?? 0;
|
|
866
978
|
const commandLocked = commandCamera?.commandTrack?.authority != null;
|
|
867
979
|
const commandTargetAvailable =
|
|
868
980
|
isCommandFeed && !disableManualControls && !commandLocked && commandTargets.length > 0;
|
|
@@ -900,6 +1012,8 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
900
1012
|
yet (see ActiveRecordingInfo.arming). Drives the ARMING pill below in
|
|
901
1013
|
place of the REC badge. */
|
|
902
1014
|
const armingActive = activeRecording?.arming === true;
|
|
1015
|
+
/* Stopped, clip not in the store yet (ActiveRecordingInfo.stopping). */
|
|
1016
|
+
const savingActive = activeRecording?.stopping === true;
|
|
903
1017
|
|
|
904
1018
|
// Ticks once a second while recording so the elapsed timer stays live;
|
|
905
1019
|
// the timer itself is derived from activeRecording.startedAt each render.
|
|
@@ -985,7 +1099,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
985
1099
|
</CameraMenuItem>
|
|
986
1100
|
))}
|
|
987
1101
|
</CameraMenu>,
|
|
988
|
-
|
|
1102
|
+
menuPortalRoot(),
|
|
989
1103
|
)}
|
|
990
1104
|
|
|
991
1105
|
{showDebugInfo &&
|
|
@@ -1175,10 +1289,14 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1175
1289
|
{
|
|
1176
1290
|
id: "fullscreen",
|
|
1177
1291
|
label: fullscreenLabel,
|
|
1292
|
+
/* While fullscreen the exit stays inline, never in the ⋮ menu,
|
|
1293
|
+
so it can be the one control the Stage keeps visible. */
|
|
1178
1294
|
stateful: false,
|
|
1295
|
+
inline: isFullscreen,
|
|
1179
1296
|
render: () => (
|
|
1180
1297
|
<OverlayIconButton
|
|
1181
1298
|
type="button"
|
|
1299
|
+
data-kc-fullscreen-exit={isFullscreen || undefined}
|
|
1182
1300
|
aria-label={fullscreenLabel}
|
|
1183
1301
|
aria-pressed={isFullscreen}
|
|
1184
1302
|
title={fullscreenLabel}
|
|
@@ -1259,7 +1377,13 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1259
1377
|
|
|
1260
1378
|
return (
|
|
1261
1379
|
<FeedBox style={feedShape}>
|
|
1262
|
-
<Stage
|
|
1380
|
+
<Stage
|
|
1381
|
+
ref={wrapRef}
|
|
1382
|
+
$pinned={chromePinned}
|
|
1383
|
+
$fullscreen={isFullscreen}
|
|
1384
|
+
data-fullscreen={isFullscreen || undefined}
|
|
1385
|
+
data-kc-signal={signal}
|
|
1386
|
+
>
|
|
1263
1387
|
{flightId === null ? (
|
|
1264
1388
|
<>
|
|
1265
1389
|
<Empty>{emptyMessage}</Empty>
|
|
@@ -1317,7 +1441,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1317
1441
|
{qualityThrottled ? " · throttled" : ""}
|
|
1318
1442
|
</QualityMeta>
|
|
1319
1443
|
</QualityMenu>,
|
|
1320
|
-
|
|
1444
|
+
menuPortalRoot(),
|
|
1321
1445
|
)}
|
|
1322
1446
|
{trackingAvailable &&
|
|
1323
1447
|
trackingMenu.open &&
|
|
@@ -1350,7 +1474,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1350
1474
|
Track target
|
|
1351
1475
|
</CameraMenuItem>
|
|
1352
1476
|
</QualityMenu>,
|
|
1353
|
-
|
|
1477
|
+
menuPortalRoot(),
|
|
1354
1478
|
)}
|
|
1355
1479
|
{commandTargetAvailable &&
|
|
1356
1480
|
commandTargetMenu.open &&
|
|
@@ -1384,15 +1508,19 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1384
1508
|
);
|
|
1385
1509
|
})}
|
|
1386
1510
|
</CameraMenu>,
|
|
1387
|
-
|
|
1511
|
+
menuPortalRoot(),
|
|
1388
1512
|
)}
|
|
1389
1513
|
{commandState &&
|
|
1390
1514
|
!outOfFlight &&
|
|
1391
1515
|
(
|
|
1392
1516
|
<>
|
|
1393
1517
|
{commandState.blank && <CommandBlank reason={commandState.blank} />}
|
|
1518
|
+
{commandState.waitingForTarget && !commandState.blank && !isDestroyed && waitingForTargetLabel && (
|
|
1519
|
+
<WaitingBadge role="status">{waitingForTargetLabel}</WaitingBadge>
|
|
1520
|
+
)}
|
|
1394
1521
|
{targetBracketsAvailable && bracketsOn && (
|
|
1395
|
-
<
|
|
1522
|
+
<LiveCommandBrackets
|
|
1523
|
+
flightId={commandCamera?.flightId ?? null}
|
|
1396
1524
|
state={commandState}
|
|
1397
1525
|
displayUt={commandDisplayUt?.ut ?? commandTrack!.ut}
|
|
1398
1526
|
utSource={commandDisplayUt?.source ?? null}
|
|
@@ -1405,6 +1533,12 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1405
1533
|
)}
|
|
1406
1534
|
</>
|
|
1407
1535
|
)}
|
|
1536
|
+
{waitingForSlot && !outOfFlight && !isDestroyed && noSlotLabel && (
|
|
1537
|
+
<NoSlotNotice role="status">{noSlotLabel}</NoSlotNotice>
|
|
1538
|
+
)}
|
|
1539
|
+
{drawsAnchors && !outOfFlight && !isDestroyed && !commandState?.blank && (
|
|
1540
|
+
<LiveAnchorMarkers flightId={flightId} video={videoEl} overlay={anchorOverlay} />
|
|
1541
|
+
)}
|
|
1408
1542
|
{outOfFlight ? (
|
|
1409
1543
|
showStandbyIcon && (
|
|
1410
1544
|
<StandbyOverlay role="status" aria-label="Standby, no active flight">
|
|
@@ -1437,12 +1571,17 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1437
1571
|
ARMING
|
|
1438
1572
|
</ArmingBadge>
|
|
1439
1573
|
)}
|
|
1440
|
-
{recordingActive && !armingActive && (
|
|
1574
|
+
{recordingActive && !armingActive && !savingActive && (
|
|
1441
1575
|
<RecBadge role="status" aria-label="Recording">
|
|
1442
1576
|
<RecDot aria-hidden="true" />
|
|
1443
1577
|
REC {formatElapsed(recordingElapsedMs)}
|
|
1444
1578
|
</RecBadge>
|
|
1445
1579
|
)}
|
|
1580
|
+
{recordingActive && savingActive && (
|
|
1581
|
+
<RecBadge role="status" aria-label="Saving recording">
|
|
1582
|
+
SAVING
|
|
1583
|
+
</RecBadge>
|
|
1584
|
+
)}
|
|
1446
1585
|
{showZoom && (
|
|
1447
1586
|
<ZoomControlsWrap $disabled={manualDisabled} aria-hidden={manualDisabled}>
|
|
1448
1587
|
<ZoomButton
|
|
@@ -1588,7 +1727,7 @@ export const CameraFeed = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1588
1727
|
);
|
|
1589
1728
|
|
|
1590
1729
|
// ---------------------------------------------------------------------------
|
|
1591
|
-
// Inline UI primitives
|
|
1730
|
+
// Inline UI primitives
|
|
1592
1731
|
// ---------------------------------------------------------------------------
|
|
1593
1732
|
|
|
1594
1733
|
function ChevronDownIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
@@ -1944,8 +2083,9 @@ const StepButtons = styled.div`
|
|
|
1944
2083
|
`;
|
|
1945
2084
|
|
|
1946
2085
|
const CameraMenu = styled.div`
|
|
1947
|
-
/* Portaled to document.body
|
|
1948
|
-
rect) keeps it clear of the
|
|
2086
|
+
/* Portaled to document.body (the fullscreen element while fullscreen):
|
|
2087
|
+
fixed position (set inline from the trigger rect) keeps it clear of the
|
|
2088
|
+
tile's overflow clipping. */
|
|
1949
2089
|
position: fixed;
|
|
1950
2090
|
z-index: 1000;
|
|
1951
2091
|
max-width: min(260px, calc(100vw - 16px));
|
|
@@ -2005,7 +2145,7 @@ const TopMeta = styled.div`
|
|
|
2005
2145
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
2006
2146
|
`;
|
|
2007
2147
|
|
|
2008
|
-
/* The quality picker: portaled
|
|
2148
|
+
/* The quality picker: portaled like CameraMenu, hung from
|
|
2009
2149
|
its action-bar trigger (fixed position set inline from the trigger rect)
|
|
2010
2150
|
so tile clipping cannot cut it off and it survives the hover-revealed
|
|
2011
2151
|
chrome fading while the pointer is over the menu. */
|
|
@@ -2191,7 +2331,7 @@ const FeedBox = styled.div`
|
|
|
2191
2331
|
justify-content: center;
|
|
2192
2332
|
`;
|
|
2193
2333
|
|
|
2194
|
-
const Stage = styled(Panel)<{ $pinned: boolean }>`
|
|
2334
|
+
const Stage = styled(Panel)<{ $pinned: boolean; $fullscreen: boolean }>`
|
|
2195
2335
|
width: min(100cqw, 100cqh * var(--kc-feed-w) / var(--kc-feed-h));
|
|
2196
2336
|
height: auto;
|
|
2197
2337
|
aspect-ratio: var(--kc-feed-w) / var(--kc-feed-h);
|
|
@@ -2236,6 +2376,31 @@ const Stage = styled(Panel)<{ $pinned: boolean }>`
|
|
|
2236
2376
|
opacity: 1;
|
|
2237
2377
|
}
|
|
2238
2378
|
`}
|
|
2379
|
+
|
|
2380
|
+
/* Fullscreen has no hover on touch and may have no pointer at all, so the
|
|
2381
|
+
exit button stays visible in its usual slot while the rest of the bar
|
|
2382
|
+
keeps its hover/focus/tap reveal. */
|
|
2383
|
+
${(p) =>
|
|
2384
|
+
p.$fullscreen &&
|
|
2385
|
+
!p.$pinned &&
|
|
2386
|
+
css`
|
|
2387
|
+
${ActionBarRow} {
|
|
2388
|
+
opacity: 1;
|
|
2389
|
+
pointer-events: auto;
|
|
2390
|
+
}
|
|
2391
|
+
${ActionBarRow} > * {
|
|
2392
|
+
transition: opacity 0.15s;
|
|
2393
|
+
}
|
|
2394
|
+
&:not(:hover):not(:focus-within) ${ActionBarRow} > :not([data-kc-fullscreen-exit]) {
|
|
2395
|
+
opacity: 0;
|
|
2396
|
+
pointer-events: none;
|
|
2397
|
+
}
|
|
2398
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2399
|
+
${ActionBarRow} > * {
|
|
2400
|
+
transition: none;
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
`}
|
|
2239
2404
|
`;
|
|
2240
2405
|
|
|
2241
2406
|
const Empty = styled.div`
|
|
@@ -2326,6 +2491,54 @@ const StaleScrim = styled.div`
|
|
|
2326
2491
|
pointer-events: none;
|
|
2327
2492
|
`;
|
|
2328
2493
|
|
|
2494
|
+
/* Centred in the picture, clear of the chrome along the edges, and wrapping so
|
|
2495
|
+
a small tile still shows the whole sentence. */
|
|
2496
|
+
const NoSlotNotice = styled.div`
|
|
2497
|
+
position: absolute;
|
|
2498
|
+
top: 50%;
|
|
2499
|
+
left: 50%;
|
|
2500
|
+
transform: translate(-50%, -50%);
|
|
2501
|
+
z-index: 2;
|
|
2502
|
+
max-width: calc(100% - 32px);
|
|
2503
|
+
padding: 6px 10px;
|
|
2504
|
+
background: rgba(0, 0, 0, 0.6);
|
|
2505
|
+
border: 1px solid rgba(255, 255, 255, 0.4);
|
|
2506
|
+
border-radius: 3px;
|
|
2507
|
+
color: rgba(255, 255, 255, 0.9);
|
|
2508
|
+
font-size: 12px;
|
|
2509
|
+
line-height: 1.4;
|
|
2510
|
+
text-align: center;
|
|
2511
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
2512
|
+
pointer-events: none;
|
|
2513
|
+
`;
|
|
2514
|
+
|
|
2515
|
+
/* Bottom centre: the zoom controls take the bottom left, pan and the stale
|
|
2516
|
+
badge the bottom right, the title and REC badge the top left and the
|
|
2517
|
+
action bar the top right. */
|
|
2518
|
+
const WaitingBadge = styled.div`
|
|
2519
|
+
position: absolute;
|
|
2520
|
+
bottom: 8px;
|
|
2521
|
+
left: 50%;
|
|
2522
|
+
transform: translateX(-50%);
|
|
2523
|
+
z-index: 2;
|
|
2524
|
+
/* Clear of the stale badge on the bottom right, even on a small tile. */
|
|
2525
|
+
max-width: calc(100% - 180px);
|
|
2526
|
+
overflow: hidden;
|
|
2527
|
+
text-overflow: ellipsis;
|
|
2528
|
+
white-space: nowrap;
|
|
2529
|
+
padding: 2px 6px;
|
|
2530
|
+
background: rgba(0, 0, 0, 0.6);
|
|
2531
|
+
border: 1px solid rgba(255, 255, 255, 0.4);
|
|
2532
|
+
border-radius: 3px;
|
|
2533
|
+
color: rgba(255, 255, 255, 0.9);
|
|
2534
|
+
font-size: 10px;
|
|
2535
|
+
font-weight: 600;
|
|
2536
|
+
letter-spacing: 0.12em;
|
|
2537
|
+
text-transform: uppercase;
|
|
2538
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
2539
|
+
pointer-events: none;
|
|
2540
|
+
`;
|
|
2541
|
+
|
|
2329
2542
|
const StaleBadge = styled.div`
|
|
2330
2543
|
position: absolute;
|
|
2331
2544
|
bottom: 8px;
|