@ksp-gonogo/kerbcast-react 1.10.3 → 1.11.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CameraFeed.d.ts +21 -0
- package/dist/CameraFeed.js +106 -5
- package/dist/CommandBrackets.d.ts +110 -0
- package/dist/CommandBrackets.js +167 -0
- 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 +5 -0
- package/dist/hooks/useReportDisplaySize.js +18 -7
- 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 +291 -2
- package/src/CameraFeed.tsx +234 -4
- package/src/CommandBrackets.test.tsx +259 -0
- package/src/CommandBrackets.tsx +341 -0
- 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.test.tsx +10 -4
- package/src/hooks/useReportDisplaySize.ts +18 -7
- package/src/hooks/useTargetBracketsPreference.ts +38 -0
- package/src/index.test.ts +2 -1
- package/src/index.ts +15 -0
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
15
|
import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
16
|
+
import { useTargetBracketsPreference } from "./hooks/useTargetBracketsPreference";
|
|
14
17
|
import { StandbyIcon } from "./StandbyIcon";
|
|
15
18
|
import { isCameraDestroyed } from "./lifecycle";
|
|
16
19
|
import { usePortalMenu } from "./menuPositioning";
|
|
@@ -59,7 +62,7 @@ function exitFullscreen() {
|
|
|
59
62
|
// ---------------------------------------------------------------------------
|
|
60
63
|
// Inner component (reads from context)
|
|
61
64
|
// ---------------------------------------------------------------------------
|
|
62
|
-
const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, cameraFilter, onDisplayedCameraChange, showDebugInfo = false, showStatic, inFlight: inFlightProp, showStandbyIcon = true, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, enableTracking = false, disableManualControls = false, enableRecording = false, recordFullResolution = false, actions, trailingActions, showActions = true, useStream, }, ref) {
|
|
65
|
+
const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, cameraFilter, onDisplayedCameraChange, showDebugInfo = false, showStatic, inFlight: inFlightProp, showStandbyIcon = true, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, enableTracking = false, disableManualControls = false, enableRecording = false, recordFullResolution = false, actions, trailingActions, showActions = true, useStream, enableTargetBrackets = true, targetBracketOverlay, displayUt: displayUtProp, }, ref) {
|
|
63
66
|
const client = useKerbcastClient();
|
|
64
67
|
// Always called (rules of hooks); `enableRecording` only gates whether the
|
|
65
68
|
// REC entry/badge render, not whether the store is subscribed.
|
|
@@ -146,6 +149,13 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
146
149
|
const resolveStream = useStream ?? useKerbcastStream;
|
|
147
150
|
const stream = resolveStream(flightId);
|
|
148
151
|
const videoRef = useRef(null);
|
|
152
|
+
/* Mirrors videoRef as state so the bracket overlay can measure the element
|
|
153
|
+
once it mounts. Setting a ref never remounts the video. */
|
|
154
|
+
const [videoEl, setVideoEl] = useState(null);
|
|
155
|
+
const attachVideo = useCallback((el) => {
|
|
156
|
+
videoRef.current = el;
|
|
157
|
+
setVideoEl(el);
|
|
158
|
+
}, []);
|
|
149
159
|
// The feed frame; fullscreen targets this and ResizeObserver measures it.
|
|
150
160
|
const wrapRef = useRef(null);
|
|
151
161
|
useEffect(() => {
|
|
@@ -245,6 +255,16 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
245
255
|
const tracking = trackMode !== TrackMode.None;
|
|
246
256
|
const manualDisabled = tracking;
|
|
247
257
|
const supportsPitch = !!camera && camera.panPitchMax - camera.panPitchMin > 0;
|
|
258
|
+
/* Command camera feeds: target brackets, readout and blank label. */
|
|
259
|
+
const commandCamera = camera?.kind === CameraKind.Command && !isDestroyed ? camera : null;
|
|
260
|
+
const isCommandFeed = commandCamera !== null;
|
|
261
|
+
const commandTrack = commandCamera?.commandTrack ?? null;
|
|
262
|
+
const commandDisplayUt = useCommandDisplayUt(commandTrack !== null, displayUtProp, commandTrack?.events, commandCamera?.flightId ?? null, videoEl);
|
|
263
|
+
const commandState = commandTrack
|
|
264
|
+
? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut)
|
|
265
|
+
: null;
|
|
266
|
+
const [bracketsOn, toggleBrackets] = useTargetBracketsPreference(commandCamera ? (commandCamera.commandCentre?.stationId ?? String(commandCamera.flightId)) : null);
|
|
267
|
+
const targetBracketsAvailable = enableTargetBrackets && isCommandFeed;
|
|
248
268
|
// -------------------------------------------------------------------------
|
|
249
269
|
// Camera selection callbacks (Next/Prev, picker)
|
|
250
270
|
// onSelectCamera is ONLY called on explicit user picks, never on auto-latch.
|
|
@@ -444,6 +464,32 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
444
464
|
closeTrackingMenu();
|
|
445
465
|
}, [client, flightId, trackMode, closeTrackingMenu]);
|
|
446
466
|
// -------------------------------------------------------------------------
|
|
467
|
+
// Command camera target picker. Lists every craft the game offers; the
|
|
468
|
+
// ineligible ones stay visible but disabled with the game's reason, and
|
|
469
|
+
// the current target follows the published subjectPersistentId. Picking
|
|
470
|
+
// one aims the camera at it. Hidden while a command authority holds the
|
|
471
|
+
// aim lock, since only it can aim then.
|
|
472
|
+
// -------------------------------------------------------------------------
|
|
473
|
+
const commandTargets = commandCamera?.commandCentre?.targets ?? [];
|
|
474
|
+
const commandTargetId = commandCamera?.commandCentre?.subjectPersistentId ?? 0;
|
|
475
|
+
const commandLocked = commandCamera?.commandTrack?.authority != null;
|
|
476
|
+
const commandTargetAvailable = isCommandFeed && !disableManualControls && !commandLocked && commandTargets.length > 0;
|
|
477
|
+
const commandTargetMenuId = useId();
|
|
478
|
+
const commandTargetMenu = usePortalMenu({
|
|
479
|
+
maxWidth: MENU_MAX_WIDTH,
|
|
480
|
+
maxHeight: MENU_MAX_HEIGHT,
|
|
481
|
+
align: "end",
|
|
482
|
+
});
|
|
483
|
+
const closeCommandTargetMenu = commandTargetMenu.close;
|
|
484
|
+
const selectCommandTarget = useCallback((persistentId) => {
|
|
485
|
+
if (flightId === null)
|
|
486
|
+
return;
|
|
487
|
+
client
|
|
488
|
+
.setCommandAim(flightId, { kind: CommandAimKind.Vessel, persistentId })
|
|
489
|
+
.catch(() => { });
|
|
490
|
+
closeCommandTargetMenu();
|
|
491
|
+
}, [client, flightId, closeCommandTargetMenu]);
|
|
492
|
+
// -------------------------------------------------------------------------
|
|
447
493
|
// REC control (opt-in). A stateful toggle that starts/stops a client-side
|
|
448
494
|
// recording of this feed via the recordings store; state is read from the
|
|
449
495
|
// store (isRecording), never held locally, so it stays correct if the
|
|
@@ -514,6 +560,11 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
514
560
|
const pipLabel = isPip ? "Exit picture in picture" : "Picture in picture";
|
|
515
561
|
const fullscreenLabel = isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
|
|
516
562
|
const recordingLabel = recordingActive ? "Stop recording" : "Start recording";
|
|
563
|
+
const bracketsLabel = "Target brackets";
|
|
564
|
+
const commandTargetName = commandTargets.find((t) => t.persistentId === commandTargetId)?.vesselName;
|
|
565
|
+
const commandTargetLabel = commandTargetName
|
|
566
|
+
? `Watching ${commandTargetName}`
|
|
567
|
+
: "Choose target";
|
|
517
568
|
const entries = [
|
|
518
569
|
...(actions?.map(feedActionToEntry) ?? []),
|
|
519
570
|
...(qualityAvailable
|
|
@@ -521,7 +572,8 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
521
572
|
{
|
|
522
573
|
id: "quality",
|
|
523
574
|
label: "Quality",
|
|
524
|
-
|
|
575
|
+
/* Stays inline: its menu anchors on this button, which the overflow menu unmounts on click. */
|
|
576
|
+
stateful: true,
|
|
525
577
|
render: () => (_jsxs(OverlayIconButton, { ref: qualityMenu.triggerRef, type: "button", "aria-label": "Quality", "aria-haspopup": "menu", "aria-expanded": qualityMenu.open, "aria-controls": qualityMenuId, title: qualityThrottled
|
|
526
578
|
? "Quality (throttled by adaptive performance)"
|
|
527
579
|
: "Quality", "$active": qualityMenu.open, onClick: qualityMenu.toggle, children: [_jsx(QualityIcon, {}), qualityThrottled && _jsx(ThrottledDot, { "aria-hidden": "true" })] })),
|
|
@@ -538,6 +590,27 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
538
590
|
},
|
|
539
591
|
]
|
|
540
592
|
: []),
|
|
593
|
+
...(targetBracketsAvailable
|
|
594
|
+
? [
|
|
595
|
+
{
|
|
596
|
+
id: "target-brackets",
|
|
597
|
+
label: bracketsLabel,
|
|
598
|
+
stateful: true,
|
|
599
|
+
render: () => (_jsx(OverlayIconButton, { type: "button", "aria-label": bracketsLabel, "aria-pressed": bracketsOn, title: bracketsLabel, "$active": bracketsOn, onClick: toggleBrackets, children: _jsx(TargetBracketsIcon, {}) })),
|
|
600
|
+
},
|
|
601
|
+
]
|
|
602
|
+
: []),
|
|
603
|
+
...(commandTargetAvailable
|
|
604
|
+
? [
|
|
605
|
+
{
|
|
606
|
+
id: "command-target",
|
|
607
|
+
label: commandTargetLabel,
|
|
608
|
+
/* Stays inline: its menu anchors on this button, which the overflow menu unmounts on click. */
|
|
609
|
+
stateful: true,
|
|
610
|
+
render: () => (_jsx(OverlayIconButton, { ref: commandTargetMenu.triggerRef, type: "button", "aria-label": "Choose target", "aria-haspopup": "menu", "aria-expanded": commandTargetMenu.open, "aria-controls": commandTargetMenuId, title: commandTargetLabel, "$active": commandTargetMenu.open, onClick: commandTargetMenu.toggle, children: _jsx(CommandTargetIcon, {}) })),
|
|
611
|
+
},
|
|
612
|
+
]
|
|
613
|
+
: []),
|
|
541
614
|
...(trackingAvailable
|
|
542
615
|
? [
|
|
543
616
|
{
|
|
@@ -589,6 +662,8 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
589
662
|
fullscreenAvailable ||
|
|
590
663
|
qualityAvailable ||
|
|
591
664
|
trackingAvailable ||
|
|
665
|
+
targetBracketsAvailable ||
|
|
666
|
+
commandTargetAvailable ||
|
|
592
667
|
enableRecording);
|
|
593
668
|
const hasActionBar = showActions &&
|
|
594
669
|
(hasCameras ||
|
|
@@ -597,7 +672,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
597
672
|
builtInActions);
|
|
598
673
|
const stepButtons = hasCameras ? (_jsxs(StepButtons, { children: [_jsx(OverlayIconButton, { type: "button", "aria-label": "Previous camera", disabled: !canStep, onClick: () => stepCamera(-1), children: "\u2039" }), _jsx(OverlayIconButton, { type: "button", "aria-label": "Next camera", disabled: !canStep, onClick: () => stepCamera(1), children: "\u203A" })] })) : null;
|
|
599
674
|
const actionBar = hasActionBar ? (_jsx(FeedActionBar, { leading: stepButtons, entries: entries })) : null;
|
|
600
|
-
return (_jsx(Stage, { ref: wrapRef, "$pinned": chromePinned, children: flightId === null ? (_jsxs(_Fragment, { children: [_jsx(Empty, { children: emptyMessage }), topOverlay, actionBar] })) : (_jsxs(_Fragment, { children: [_jsx(StyledVideo, { ref:
|
|
675
|
+
return (_jsx(Stage, { ref: wrapRef, "$pinned": chromePinned, children: flightId === null ? (_jsxs(_Fragment, { children: [_jsx(Empty, { children: emptyMessage }), topOverlay, actionBar] })) : (_jsxs(_Fragment, { children: [_jsx(StyledVideo, { ref: attachVideo, autoPlay: true, playsInline: true, muted: true, controls: false, onClick: () => setChromePinned((v) => !v) }), topOverlay, actionBar, qualityAvailable &&
|
|
601
676
|
qualityMenu.open &&
|
|
602
677
|
qualityMenu.position &&
|
|
603
678
|
camera &&
|
|
@@ -605,7 +680,16 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
605
680
|
trackingMenu.open &&
|
|
606
681
|
trackingMenu.position &&
|
|
607
682
|
camera &&
|
|
608
|
-
createPortal(_jsxs(QualityMenu, { ref: trackingMenu.menuRef, id: trackingMenuId, role: "menu", "aria-label": "Auto-track", style: trackingMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.ActiveVessel, "$selected": trackMode === TrackMode.ActiveVessel, onClick: () => selectTrack(TrackMode.ActiveVessel), children: "Track active vessel" }), _jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.Target, "$selected": trackMode === TrackMode.Target, onClick: () => selectTrack(TrackMode.Target), children: "Track target" })] }), document.body),
|
|
683
|
+
createPortal(_jsxs(QualityMenu, { ref: trackingMenu.menuRef, id: trackingMenuId, role: "menu", "aria-label": "Auto-track", style: trackingMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.ActiveVessel, "$selected": trackMode === TrackMode.ActiveVessel, onClick: () => selectTrack(TrackMode.ActiveVessel), children: "Track active vessel" }), _jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.Target, "$selected": trackMode === TrackMode.Target, onClick: () => selectTrack(TrackMode.Target), children: "Track target" })] }), document.body), commandTargetAvailable &&
|
|
684
|
+
commandTargetMenu.open &&
|
|
685
|
+
commandTargetMenu.position &&
|
|
686
|
+
createPortal(_jsx(CameraMenu, { ref: commandTargetMenu.menuRef, id: commandTargetMenuId, role: "menu", "aria-label": "Target", style: commandTargetMenu.position, children: commandTargets.map((t) => {
|
|
687
|
+
const eligible = t.eligibility === CommandTargetEligibility.Ok;
|
|
688
|
+
const current = t.persistentId === commandTargetId;
|
|
689
|
+
return (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": current, "$selected": current, disabled: !eligible, title: eligible ? undefined : t.eligibilityText, onClick: () => selectCommandTarget(t.persistentId), children: [t.vesselName, t.rangeM != null ? ` · ${formatCommandRange(t.rangeM)}` : "", eligible ? "" : ` · ${t.eligibilityText}`] }, t.persistentId));
|
|
690
|
+
}) }), document.body), commandState &&
|
|
691
|
+
!outOfFlight &&
|
|
692
|
+
(_jsxs(_Fragment, { children: [commandState.blank && _jsx(CommandBlank, { reason: commandState.blank }), targetBracketsAvailable && bracketsOn && (_jsx(CommandBrackets, { state: commandState, displayUt: commandDisplayUt?.ut ?? commandTrack.ut, utSource: commandDisplayUt?.source ?? null, video: videoEl, fallbackSize: camera ? { width: camera.renderWidth, height: camera.renderHeight } : null, overlay: targetBracketOverlay }))] })), outOfFlight ? (showStandbyIcon && (_jsx(StandbyOverlay, { role: "status", "aria-label": "Standby, no active flight", children: _jsx(StandbyIconWrap, { children: _jsx(StandbyIcon, { size: 40 }) }) }))) : (isDestroyed && (_jsx(SignalLostOverlay, { role: "status", "aria-label": "Signal lost", children: _jsx(SignalLostText, { "$animated": effectiveShowStatic, children: "SIGNAL LOST" }) }))), !outOfFlight && !effectiveShowStatic && isStale && !isDestroyed && (_jsxs(_Fragment, { children: [_jsx(StaleScrim, { "aria-hidden": "true" }), _jsxs(StaleBadge, { role: "status", "aria-label": "Feed stale", children: [_jsx(StaleIcon, {}), "Stale"] })] })), recordingActive && armingActive && (_jsxs(ArmingBadge, { role: "status", "aria-label": "Arming", children: [_jsx(ArmingDot, { "aria-hidden": "true" }), "ARMING"] })), recordingActive && !armingActive && (_jsxs(RecBadge, { role: "status", "aria-label": "Recording", children: [_jsx(RecDot, { "aria-hidden": "true" }), "REC ", formatElapsed(recordingElapsedMs)] })), showZoom && (_jsxs(ZoomControlsWrap, { "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(ZoomButton, { type: "button", "aria-label": "Zoom in", "$pos": "top", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
|
|
609
693
|
if (e.detail === 0)
|
|
610
694
|
controllerRef.current?.nudgeZoom(-1);
|
|
611
695
|
}, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
|
|
@@ -665,6 +749,14 @@ function FullscreenExitIcon() {
|
|
|
665
749
|
function QualityIcon() {
|
|
666
750
|
return (_jsxs("svg", { ...iconProps, children: [_jsx("path", { d: "M2 4.5h12M2 8h12M2 11.5h12" }), _jsx("circle", { cx: "10.5", cy: "4.5", r: "1.6", fill: "currentColor", stroke: "none" }), _jsx("circle", { cx: "5.5", cy: "8", r: "1.6", fill: "currentColor", stroke: "none" }), _jsx("circle", { cx: "8.5", cy: "11.5", r: "1.6", fill: "currentColor", stroke: "none" })] }));
|
|
667
751
|
}
|
|
752
|
+
/* Target brackets: the top-left and bottom-right corners around a centre dot. */
|
|
753
|
+
function TargetBracketsIcon() {
|
|
754
|
+
return (_jsxs("svg", { ...iconProps, children: [_jsx("path", { d: "M2.5 7V2.5H7M13.5 9v4.5H9" }), _jsx("circle", { cx: "8", cy: "8", r: "1.2", fill: "currentColor", stroke: "none" })] }));
|
|
755
|
+
}
|
|
756
|
+
/* Command camera target: a craft silhouette inside a selection ring. */
|
|
757
|
+
function CommandTargetIcon() {
|
|
758
|
+
return (_jsxs("svg", { ...iconProps, children: [_jsx("circle", { cx: "8", cy: "8", r: "6" }), _jsx("path", { d: "M8 4.5l1.6 4H6.4zM6.6 8.5l-.9 2.5M9.4 8.5l.9 2.5" })] }));
|
|
759
|
+
}
|
|
668
760
|
/* Auto-track: a crosshair / reticle. */
|
|
669
761
|
function CrosshairIcon() {
|
|
670
762
|
return (_jsxs("svg", { ...iconProps, children: [_jsx("circle", { cx: "8", cy: "8", r: "4.5" }), _jsx("path", { d: "M8 1v2.5M8 12.5V15M1 8h2.5M12.5 8H15" })] }));
|
|
@@ -941,6 +1033,15 @@ const CameraMenuItem = styled.button `
|
|
|
941
1033
|
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
942
1034
|
outline-offset: -2px;
|
|
943
1035
|
}
|
|
1036
|
+
|
|
1037
|
+
&:disabled {
|
|
1038
|
+
cursor: default;
|
|
1039
|
+
color: rgba(255, 255, 255, 0.45);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
&:disabled:not([aria-checked="true"]) {
|
|
1043
|
+
background: transparent;
|
|
1044
|
+
}
|
|
944
1045
|
`;
|
|
945
1046
|
const TopMeta = styled.div `
|
|
946
1047
|
font-size: 11px;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { type EffectiveCommandTrack, type FrameUtSource } from "@ksp-gonogo/kerbcast";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
/** Default colours; each is overridable through the named CSS custom property. */
|
|
4
|
+
export declare const COMMAND_BRACKET_COLOURS: {
|
|
5
|
+
readonly clear: "var(--kerbcast-bracket-clear, #4caf50)";
|
|
6
|
+
readonly occluded: "var(--kerbcast-bracket-occluded, #f44336)";
|
|
7
|
+
};
|
|
8
|
+
export interface ContentRect {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Where `object-fit: contain` media actually lands inside its box. Falls back
|
|
16
|
+
* to the whole box while the media size is unknown.
|
|
17
|
+
*/
|
|
18
|
+
export declare function containedContentRect(boxWidth: number, boxHeight: number, mediaWidth: number, mediaHeight: number): ContentRect;
|
|
19
|
+
export interface BracketPoint {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
}
|
|
23
|
+
/** The bracket box, in the pixel space of the bracket SVG (the video element's box). */
|
|
24
|
+
export interface BracketGeometry {
|
|
25
|
+
/** Box centre, which is the aim point. */
|
|
26
|
+
cx: number;
|
|
27
|
+
cy: number;
|
|
28
|
+
/** Half the box side in display px. */
|
|
29
|
+
half: number;
|
|
30
|
+
/** Length of each corner stroke. */
|
|
31
|
+
arm: number;
|
|
32
|
+
stroke: number;
|
|
33
|
+
/** The box's top-left and bottom-right corner points. */
|
|
34
|
+
topLeft: BracketPoint;
|
|
35
|
+
bottomRight: BracketPoint;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The bracket box centred on the displayed frame. `bracketHalfPx` is in
|
|
39
|
+
* capture px of a `frameHeightPx`-tall frame, so it scales by the displayed
|
|
40
|
+
* height; a missing half-size draws the minimum box.
|
|
41
|
+
*/
|
|
42
|
+
export declare function bracketGeometry(rect: ContentRect, bracketHalfPx: number | null, frameHeightPx: number): BracketGeometry;
|
|
43
|
+
export type CommandBracketCorner = "topLeft" | "bottomRight";
|
|
44
|
+
export interface CommandBracketCornerStyle {
|
|
45
|
+
colour: string;
|
|
46
|
+
dashed: boolean;
|
|
47
|
+
}
|
|
48
|
+
/** Changes to one default corner; unset fields keep the default. */
|
|
49
|
+
export interface CommandBracketCornerOverride {
|
|
50
|
+
colour?: string;
|
|
51
|
+
dashed?: boolean;
|
|
52
|
+
hidden?: boolean;
|
|
53
|
+
}
|
|
54
|
+
export type CommandBracketCornerOverrides = Partial<Record<CommandBracketCorner, CommandBracketCornerOverride>>;
|
|
55
|
+
/** Everything a bracket overlay needs to line up with the drawn brackets. */
|
|
56
|
+
export interface CommandBracketContext {
|
|
57
|
+
geometry: BracketGeometry;
|
|
58
|
+
/** Size of the bracket SVG, the video element's box. */
|
|
59
|
+
width: number;
|
|
60
|
+
height: number;
|
|
61
|
+
/** Frame UT the brackets describe. */
|
|
62
|
+
displayUt: number;
|
|
63
|
+
utSource: FrameUtSource | "override" | null;
|
|
64
|
+
state: EffectiveCommandTrack;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Consumer additions to a command feed's brackets. Each part is optional and
|
|
68
|
+
* is called on every render with the context for the displayed frame.
|
|
69
|
+
*/
|
|
70
|
+
export interface CommandBracketOverlay {
|
|
71
|
+
/**
|
|
72
|
+
* SVG content drawn inside the bracket SVG above the default corners, in
|
|
73
|
+
* the same pixel space. Runs in every state, with or without a fix and
|
|
74
|
+
* over a blanked feed; `ctx.state` says which.
|
|
75
|
+
*/
|
|
76
|
+
render?: (ctx: CommandBracketContext) => ReactNode;
|
|
77
|
+
/** Restyle or hide the default corners. */
|
|
78
|
+
corners?: CommandBracketCornerOverrides | ((ctx: CommandBracketContext) => CommandBracketCornerOverrides | null | undefined);
|
|
79
|
+
/**
|
|
80
|
+
* The readout text under the box. Receives the default text, which is
|
|
81
|
+
* empty while the feed is blanked; return a replacement (append to keep
|
|
82
|
+
* it), or null to hide the readout. Runs in every state.
|
|
83
|
+
*/
|
|
84
|
+
readout?: (defaultText: string, ctx: CommandBracketContext) => string | null;
|
|
85
|
+
}
|
|
86
|
+
export interface CommandBracketsProps {
|
|
87
|
+
state: EffectiveCommandTrack;
|
|
88
|
+
displayUt: number;
|
|
89
|
+
/** Where `displayUt` came from, mirrored to the readout as `data-ut-source`. */
|
|
90
|
+
utSource?: FrameUtSource | "override" | null;
|
|
91
|
+
video: HTMLVideoElement | null;
|
|
92
|
+
/** Frame aspect to assume until the video reports its own size. */
|
|
93
|
+
fallbackSize?: {
|
|
94
|
+
width: number;
|
|
95
|
+
height: number;
|
|
96
|
+
} | null;
|
|
97
|
+
overlay?: CommandBracketOverlay | null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Target brackets for a command camera feed: the aim point sits at frame
|
|
101
|
+
* centre, so the box is always centred on the displayed video. Both corners
|
|
102
|
+
* show the camera axis by default; an overlay can restyle either and draw
|
|
103
|
+
* its own marks. Without a fix, or over a blanked feed, the default corners
|
|
104
|
+
* are left out but the overlay and readout still run.
|
|
105
|
+
*/
|
|
106
|
+
export declare function CommandBrackets({ state, displayUt, utSource, video, fallbackSize, overlay, }: CommandBracketsProps): import("react").JSX.Element;
|
|
107
|
+
/** Reason a command feed is blanked, centred on black over the feed. */
|
|
108
|
+
export declare function CommandBlank({ reason }: {
|
|
109
|
+
reason: string;
|
|
110
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { commandReadout, } from "@ksp-gonogo/kerbcast";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import styled from "styled-components";
|
|
5
|
+
const BRACKET_STROKE_PX = 2;
|
|
6
|
+
const BRACKET_MIN_HALF_PX = 14;
|
|
7
|
+
const BRACKET_ARM_FRACTION = 0.3;
|
|
8
|
+
const BRACKET_MIN_ARM_PX = 6;
|
|
9
|
+
const READOUT_GAP_PX = 11;
|
|
10
|
+
/** Default colours; each is overridable through the named CSS custom property. */
|
|
11
|
+
export const COMMAND_BRACKET_COLOURS = {
|
|
12
|
+
clear: "var(--kerbcast-bracket-clear, #4caf50)",
|
|
13
|
+
occluded: "var(--kerbcast-bracket-occluded, #f44336)",
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Where `object-fit: contain` media actually lands inside its box. Falls back
|
|
17
|
+
* to the whole box while the media size is unknown.
|
|
18
|
+
*/
|
|
19
|
+
export function containedContentRect(boxWidth, boxHeight, mediaWidth, mediaHeight) {
|
|
20
|
+
if (mediaWidth <= 0 || mediaHeight <= 0 || boxWidth <= 0 || boxHeight <= 0) {
|
|
21
|
+
return { x: 0, y: 0, width: Math.max(0, boxWidth), height: Math.max(0, boxHeight) };
|
|
22
|
+
}
|
|
23
|
+
const scale = Math.min(boxWidth / mediaWidth, boxHeight / mediaHeight);
|
|
24
|
+
const width = mediaWidth * scale;
|
|
25
|
+
const height = mediaHeight * scale;
|
|
26
|
+
return { x: (boxWidth - width) / 2, y: (boxHeight - height) / 2, width, height };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The bracket box centred on the displayed frame. `bracketHalfPx` is in
|
|
30
|
+
* capture px of a `frameHeightPx`-tall frame, so it scales by the displayed
|
|
31
|
+
* height; a missing half-size draws the minimum box.
|
|
32
|
+
*/
|
|
33
|
+
export function bracketGeometry(rect, bracketHalfPx, frameHeightPx) {
|
|
34
|
+
const scale = frameHeightPx > 0 ? rect.height / frameHeightPx : 1;
|
|
35
|
+
const half = Math.max(BRACKET_MIN_HALF_PX, (bracketHalfPx ?? 0) * scale);
|
|
36
|
+
const arm = Math.max(BRACKET_MIN_ARM_PX, BRACKET_ARM_FRACTION * half * 2);
|
|
37
|
+
const cx = rect.x + rect.width / 2;
|
|
38
|
+
const cy = rect.y + rect.height / 2;
|
|
39
|
+
return {
|
|
40
|
+
cx,
|
|
41
|
+
cy,
|
|
42
|
+
half,
|
|
43
|
+
arm,
|
|
44
|
+
stroke: BRACKET_STROKE_PX,
|
|
45
|
+
topLeft: { x: cx - half, y: cy - half },
|
|
46
|
+
bottomRight: { x: cx + half, y: cy + half },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function cameraCornerStyle(state) {
|
|
50
|
+
return state.occluded
|
|
51
|
+
? { colour: COMMAND_BRACKET_COLOURS.occluded, dashed: true }
|
|
52
|
+
: { colour: COMMAND_BRACKET_COLOURS.clear, dashed: false };
|
|
53
|
+
}
|
|
54
|
+
function applyCorner(base, override) {
|
|
55
|
+
return {
|
|
56
|
+
colour: override?.colour ?? base.colour,
|
|
57
|
+
dashed: override?.dashed ?? base.dashed,
|
|
58
|
+
hidden: override?.hidden ?? false,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Tracks the rendered box and intrinsic size of a video element. */
|
|
62
|
+
function useVideoBox(video) {
|
|
63
|
+
const [box, setBox] = useState({ boxW: 0, boxH: 0, mediaW: 0, mediaH: 0 });
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!video)
|
|
66
|
+
return;
|
|
67
|
+
const measure = () => {
|
|
68
|
+
const next = {
|
|
69
|
+
boxW: video.clientWidth,
|
|
70
|
+
boxH: video.clientHeight,
|
|
71
|
+
mediaW: video.videoWidth,
|
|
72
|
+
mediaH: video.videoHeight,
|
|
73
|
+
};
|
|
74
|
+
setBox((prev) => prev.boxW === next.boxW &&
|
|
75
|
+
prev.boxH === next.boxH &&
|
|
76
|
+
prev.mediaW === next.mediaW &&
|
|
77
|
+
prev.mediaH === next.mediaH
|
|
78
|
+
? prev
|
|
79
|
+
: next);
|
|
80
|
+
};
|
|
81
|
+
measure();
|
|
82
|
+
const observer = new ResizeObserver(measure);
|
|
83
|
+
observer.observe(video);
|
|
84
|
+
video.addEventListener("loadedmetadata", measure);
|
|
85
|
+
video.addEventListener("resize", measure);
|
|
86
|
+
return () => {
|
|
87
|
+
observer.disconnect();
|
|
88
|
+
video.removeEventListener("loadedmetadata", measure);
|
|
89
|
+
video.removeEventListener("resize", measure);
|
|
90
|
+
};
|
|
91
|
+
}, [video]);
|
|
92
|
+
return box;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Target brackets for a command camera feed: the aim point sits at frame
|
|
96
|
+
* centre, so the box is always centred on the displayed video. Both corners
|
|
97
|
+
* show the camera axis by default; an overlay can restyle either and draw
|
|
98
|
+
* its own marks. Without a fix, or over a blanked feed, the default corners
|
|
99
|
+
* are left out but the overlay and readout still run.
|
|
100
|
+
*/
|
|
101
|
+
export function CommandBrackets({ state, displayUt, utSource, video, fallbackSize, overlay, }) {
|
|
102
|
+
const { boxW, boxH, mediaW, mediaH } = useVideoBox(video);
|
|
103
|
+
const rect = containedContentRect(boxW, boxH, mediaW || fallbackSize?.width || 0, mediaH || fallbackSize?.height || 0);
|
|
104
|
+
const g = bracketGeometry(rect, state.bracketHalfPx, state.frameHeightPx || mediaH);
|
|
105
|
+
const ctx = {
|
|
106
|
+
geometry: g,
|
|
107
|
+
width: boxW,
|
|
108
|
+
height: boxH,
|
|
109
|
+
displayUt,
|
|
110
|
+
utSource: utSource ?? null,
|
|
111
|
+
state,
|
|
112
|
+
};
|
|
113
|
+
const overrides = typeof overlay?.corners === "function" ? overlay.corners(ctx) : overlay?.corners;
|
|
114
|
+
const camera = cameraCornerStyle(state);
|
|
115
|
+
const topLeft = applyCorner(camera, overrides?.topLeft);
|
|
116
|
+
const bottomRight = applyCorner(camera, overrides?.bottomRight);
|
|
117
|
+
const { x: left, y: top } = g.topLeft;
|
|
118
|
+
const { x: right, y: bottom } = g.bottomRight;
|
|
119
|
+
const blanked = !!state.blank;
|
|
120
|
+
const drawCorners = state.hasFix && !blanked;
|
|
121
|
+
const defaultReadout = blanked ? "" : commandReadout(state);
|
|
122
|
+
const readout = overlay?.readout ? overlay.readout(defaultReadout, ctx) : defaultReadout;
|
|
123
|
+
return (_jsxs(_Fragment, { children: [(drawCorners || overlay?.render) && (_jsxs(BracketSvg, { "aria-hidden": "true", "data-camera-axis": state.camera, width: boxW, height: boxH, children: [drawCorners && !topLeft.hidden && (_jsx("path", { "data-corner": "topLeft", d: `M${left} ${top + g.arm}L${left} ${top}L${left + g.arm} ${top}`, fill: "none", strokeWidth: g.stroke, strokeDasharray: topLeft.dashed ? "3 2" : undefined, style: { stroke: topLeft.colour } })), drawCorners && !bottomRight.hidden && (_jsx("path", { "data-corner": "bottomRight", d: `M${right} ${bottom - g.arm}L${right} ${bottom}L${right - g.arm} ${bottom}`, fill: "none", strokeWidth: g.stroke, strokeDasharray: bottomRight.dashed ? "3 2" : undefined, style: { stroke: bottomRight.colour } })), overlay?.render && _jsx("g", { "data-bracket-overlay": "", children: overlay.render(ctx) })] })), readout && (_jsx(Readout, { "data-ut-source": utSource ?? undefined, style: { left: g.cx, top: bottom + READOUT_GAP_PX }, children: readout }))] }));
|
|
124
|
+
}
|
|
125
|
+
/** Reason a command feed is blanked, centred on black over the feed. */
|
|
126
|
+
export function CommandBlank({ reason }) {
|
|
127
|
+
return (_jsx(BlankOverlay, { role: "status", "aria-label": "Feed blanked", children: _jsx(BlankText, { children: reason }) }));
|
|
128
|
+
}
|
|
129
|
+
const BracketSvg = styled.svg `
|
|
130
|
+
position: absolute;
|
|
131
|
+
top: 0;
|
|
132
|
+
left: 0;
|
|
133
|
+
z-index: 1;
|
|
134
|
+
overflow: visible;
|
|
135
|
+
pointer-events: none;
|
|
136
|
+
`;
|
|
137
|
+
const Readout = styled.div `
|
|
138
|
+
position: absolute;
|
|
139
|
+
z-index: 1;
|
|
140
|
+
transform: translateX(-50%);
|
|
141
|
+
white-space: nowrap;
|
|
142
|
+
color: #fff;
|
|
143
|
+
font-size: 10px;
|
|
144
|
+
font-weight: 600;
|
|
145
|
+
letter-spacing: 0.12em;
|
|
146
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
147
|
+
pointer-events: none;
|
|
148
|
+
`;
|
|
149
|
+
const BlankOverlay = styled.div `
|
|
150
|
+
position: absolute;
|
|
151
|
+
inset: 0;
|
|
152
|
+
z-index: 1;
|
|
153
|
+
display: flex;
|
|
154
|
+
align-items: center;
|
|
155
|
+
justify-content: center;
|
|
156
|
+
padding: 1rem;
|
|
157
|
+
background: #000;
|
|
158
|
+
pointer-events: none;
|
|
159
|
+
`;
|
|
160
|
+
const BlankText = styled.span `
|
|
161
|
+
color: rgba(255, 255, 255, 0.85);
|
|
162
|
+
font-size: 0.85rem;
|
|
163
|
+
font-weight: 600;
|
|
164
|
+
letter-spacing: 0.12em;
|
|
165
|
+
text-align: center;
|
|
166
|
+
text-transform: uppercase;
|
|
167
|
+
`;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type FrameUtSource, type CommandTrackEvent } from "@ksp-gonogo/kerbcast";
|
|
2
|
+
export { MAX_UT_EXTRAPOLATION_MS, extrapolateCaptureUt } from "@ksp-gonogo/kerbcast";
|
|
3
|
+
/** Real milliseconds until the first event after `displayUt`, or `null` when none is ahead. */
|
|
4
|
+
export declare function msUntilNextEvent(events: readonly CommandTrackEvent[] | undefined, displayUt: number, warpRate: number): number | null;
|
|
5
|
+
/** Whether an event's UT lies in `(from, to]`, i.e. moving the display from `from` to `to` changes the state in force. */
|
|
6
|
+
export declare function crossesEvent(events: readonly CommandTrackEvent[] | undefined, from: number, to: number): boolean;
|
|
7
|
+
/** The UT a command feed is showing and where it came from; `"override"` is a host's own value. */
|
|
8
|
+
export interface CommandDisplayUt {
|
|
9
|
+
ut: number;
|
|
10
|
+
source: FrameUtSource | "override";
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The UT a command feed is showing. An explicit `override` (a host with its
|
|
14
|
+
* own delayed playout) wins. Otherwise, given the feed's `flightId` and the
|
|
15
|
+
* `video` element showing it, the presented frame's own capture stamp is
|
|
16
|
+
* used (`source: "exact"`); without one, the client's capture clock is
|
|
17
|
+
* interpolated to now (`"estimated"`). Re-evaluated twice a second while
|
|
18
|
+
* `enabled` so readout ages keep counting, and again the moment the
|
|
19
|
+
* displayed UT reaches the next of `events`, so a state change lands on its
|
|
20
|
+
* frame rather than on the next tick. `null` when nothing is known.
|
|
21
|
+
*/
|
|
22
|
+
export declare function useCommandDisplayUt(enabled: boolean, override?: number, events?: readonly CommandTrackEvent[], flightId?: number | null, video?: HTMLVideoElement | null): CommandDisplayUt | null;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useEffect, useReducer, useRef } from "react";
|
|
2
|
+
import { extrapolateCaptureUt, } from "@ksp-gonogo/kerbcast";
|
|
3
|
+
import { useKerbcastClient } from "../context";
|
|
4
|
+
import { nowMs } from "../timing";
|
|
5
|
+
export { MAX_UT_EXTRAPOLATION_MS, extrapolateCaptureUt } from "@ksp-gonogo/kerbcast";
|
|
6
|
+
const TICK_MS = 500;
|
|
7
|
+
/** Real milliseconds until the first event after `displayUt`, or `null` when none is ahead. */
|
|
8
|
+
export function msUntilNextEvent(events, displayUt, warpRate) {
|
|
9
|
+
if (!events?.length || !(warpRate > 0))
|
|
10
|
+
return null;
|
|
11
|
+
let next = Infinity;
|
|
12
|
+
for (const e of events)
|
|
13
|
+
if (e.ut > displayUt && e.ut < next)
|
|
14
|
+
next = e.ut;
|
|
15
|
+
return next === Infinity ? null : ((next - displayUt) / warpRate) * 1000;
|
|
16
|
+
}
|
|
17
|
+
/** Whether an event's UT lies in `(from, to]`, i.e. moving the display from `from` to `to` changes the state in force. */
|
|
18
|
+
export function crossesEvent(events, from, to) {
|
|
19
|
+
if (!events?.length)
|
|
20
|
+
return false;
|
|
21
|
+
const lo = Math.min(from, to);
|
|
22
|
+
const hi = Math.max(from, to);
|
|
23
|
+
return events.some((e) => e.ut > lo && e.ut <= hi);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The UT a command feed is showing. An explicit `override` (a host with its
|
|
27
|
+
* own delayed playout) wins. Otherwise, given the feed's `flightId` and the
|
|
28
|
+
* `video` element showing it, the presented frame's own capture stamp is
|
|
29
|
+
* used (`source: "exact"`); without one, the client's capture clock is
|
|
30
|
+
* interpolated to now (`"estimated"`). Re-evaluated twice a second while
|
|
31
|
+
* `enabled` so readout ages keep counting, and again the moment the
|
|
32
|
+
* displayed UT reaches the next of `events`, so a state change lands on its
|
|
33
|
+
* frame rather than on the next tick. `null` when nothing is known.
|
|
34
|
+
*/
|
|
35
|
+
export function useCommandDisplayUt(enabled, override, events, flightId, video) {
|
|
36
|
+
const client = useKerbcastClient();
|
|
37
|
+
const sample = useRef({
|
|
38
|
+
captureUt: client.clock.captureUt,
|
|
39
|
+
warpRate: client.clock.warpRate,
|
|
40
|
+
at: nowMs(),
|
|
41
|
+
});
|
|
42
|
+
const frame = useRef(null);
|
|
43
|
+
const shown = useRef(null);
|
|
44
|
+
const eventsRef = useRef(events);
|
|
45
|
+
eventsRef.current = events;
|
|
46
|
+
const [, tick] = useReducer((c) => c + 1, 0);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const take = () => {
|
|
49
|
+
const { captureUt, warpRate } = client.clock;
|
|
50
|
+
sample.current = { captureUt, warpRate, at: nowMs() };
|
|
51
|
+
};
|
|
52
|
+
take();
|
|
53
|
+
return client.on("settings-change", take);
|
|
54
|
+
}, [client]);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!enabled)
|
|
57
|
+
return;
|
|
58
|
+
const interval = setInterval(tick, TICK_MS);
|
|
59
|
+
return () => clearInterval(interval);
|
|
60
|
+
}, [enabled]);
|
|
61
|
+
/* Exact frames re-render only when they change what is drawn: a switch of
|
|
62
|
+
source, or a step across an event. Ages catch up on the tick. */
|
|
63
|
+
const overriding = override !== undefined;
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
frame.current = null;
|
|
66
|
+
if (!enabled || overriding || flightId == null || !video)
|
|
67
|
+
return;
|
|
68
|
+
return client.watchDisplayedFrames(flightId, video, (f) => {
|
|
69
|
+
frame.current = f.source === "exact" ? f : null;
|
|
70
|
+
const prev = shown.current;
|
|
71
|
+
if (!frame.current) {
|
|
72
|
+
if (prev?.source === "exact")
|
|
73
|
+
tick();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (prev === null ||
|
|
77
|
+
prev.source !== "exact" ||
|
|
78
|
+
crossesEvent(eventsRef.current, prev.ut, f.ut)) {
|
|
79
|
+
tick();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}, [client, enabled, overriding, flightId, video]);
|
|
83
|
+
const { captureUt, warpRate, at } = sample.current;
|
|
84
|
+
const exact = frame.current;
|
|
85
|
+
const displayUt = overriding
|
|
86
|
+
? { ut: override, source: "override" }
|
|
87
|
+
: exact
|
|
88
|
+
? { ut: exact.ut, source: "exact" }
|
|
89
|
+
: captureUt === null
|
|
90
|
+
? null
|
|
91
|
+
: { ut: extrapolateCaptureUt(captureUt, warpRate, at, nowMs()), source: "estimated" };
|
|
92
|
+
shown.current = displayUt;
|
|
93
|
+
const untilEdge = enabled && displayUt?.source === "estimated"
|
|
94
|
+
? msUntilNextEvent(events, displayUt.ut, warpRate)
|
|
95
|
+
: null;
|
|
96
|
+
/* Edges further out than a tick are caught by a later render's timer. */
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (untilEdge === null || untilEdge > TICK_MS)
|
|
99
|
+
return;
|
|
100
|
+
const timer = setTimeout(tick, Math.max(0, untilEdge) + 1);
|
|
101
|
+
return () => clearTimeout(timer);
|
|
102
|
+
});
|
|
103
|
+
return displayUt;
|
|
104
|
+
}
|