@ksp-gonogo/kerbcast-react 1.12.0-rc.2 → 1.12.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/README.md +10 -2
- package/dist/AnchorMarkers.d.ts +7 -2
- package/dist/AnchorMarkers.js +8 -3
- package/dist/CameraFeed.d.ts +16 -9
- package/dist/CameraFeed.js +56 -7
- package/dist/CommandBrackets.d.ts +4 -2
- package/dist/CommandBrackets.js +3 -2
- package/dist/context.d.ts +27 -3
- package/dist/context.js +152 -27
- package/dist/hooks/useCommandDisplayUt.d.ts +3 -2
- package/dist/hooks/useCommandDisplayUt.js +3 -2
- package/dist/hooks/useKerbcastStream.d.ts +3 -1
- package/dist/hooks/useKerbcastStream.js +3 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/AnchorMarkers.tsx +9 -2
- package/src/CameraFeed.test.tsx +477 -12
- package/src/CameraFeed.tsx +78 -16
- package/src/CommandBrackets.tsx +9 -2
- package/src/context.test.tsx +349 -3
- package/src/context.tsx +201 -31
- package/src/hooks/useCommandDisplayUt.ts +3 -2
- package/src/hooks/useKerbcastStream.test.tsx +102 -5
- package/src/hooks/useKerbcastStream.ts +3 -1
- package/src/index.ts +2 -0
package/README.md
CHANGED
|
@@ -52,7 +52,10 @@ Commonly used props (`CameraFeedProps` has the full set with doc
|
|
|
52
52
|
comments):
|
|
53
53
|
|
|
54
54
|
- `flightId: number | null`. The camera to show. `null` latches the
|
|
55
|
-
first live camera and re-picks if it dies.
|
|
55
|
+
first live camera and re-picks if it dies. A shown camera that drops
|
|
56
|
+
out of the camera list stays on screen for `KERBCAST_RELEASE_GRACE_MS`
|
|
57
|
+
before the feed falls back. A recording of it still ends at once
|
|
58
|
+
(`camera-gone`)
|
|
56
59
|
- `onSelectCamera` / `onDisplayedCameraChange`. The user picked a
|
|
57
60
|
camera, or the resolved camera changed (including auto-latch).
|
|
58
61
|
- `enableFullscreen`, `enablePictureInPicture`, `enableQualityControl`.
|
|
@@ -184,7 +187,12 @@ quality follows how large it is shown (pass `reportSize={false}` to opt out).
|
|
|
184
187
|
re-rendering as cameras appear, change, or are destroyed.
|
|
185
188
|
- `useKerbcastStream(flightId): MediaStream | null`. The video stream
|
|
186
189
|
for one camera, acquiring and releasing its subscription slot. This is
|
|
187
|
-
what `CameraFeed` uses internally.
|
|
190
|
+
what `CameraFeed` uses internally. A released slot is kept for
|
|
191
|
+
`KERBCAST_RELEASE_GRACE_MS` (2000 ms), so a feed that remounts inside
|
|
192
|
+
that window picks up the running stream instead of starting it again.
|
|
193
|
+
It is let go at once when a feed switches to another camera, or when a
|
|
194
|
+
feed is waiting for a slot. Pass `releaseGraceMs` to `KerbcastProvider`
|
|
195
|
+
(or to `createClientSubscriptions`) for a different grace, or 0 for none
|
|
188
196
|
- `useKerbcastInFlight(): boolean | undefined`. Whether KSP is in a
|
|
189
197
|
flight scene. `undefined` until the first signal arrives, so you can
|
|
190
198
|
avoid flashing an out-of-flight state on connect.
|
package/dist/AnchorMarkers.d.ts
CHANGED
|
@@ -119,10 +119,15 @@ export declare function AnchorMarkers({ frame, video, overlay, fit }: AnchorMark
|
|
|
119
119
|
* so a feed's other UI does not re-render at the frame rate.
|
|
120
120
|
*/
|
|
121
121
|
export declare function useDisplayedFrame(enabled: boolean, flightId: number | null, video: HTMLVideoElement | null): DisplayedFrameUt | null;
|
|
122
|
-
/**
|
|
123
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Self-subscribing markers for a feed: watches its displayed frames and
|
|
124
|
+
* draws their anchors, or draws `frame`'s while a host's own delayed
|
|
125
|
+
* playout gives it.
|
|
126
|
+
*/
|
|
127
|
+
export declare function LiveAnchorMarkers({ flightId, video, overlay, fit, frame: given, }: {
|
|
124
128
|
flightId: number | null;
|
|
125
129
|
video: HTMLVideoElement | null;
|
|
126
130
|
overlay?: AnchorOverlay | null;
|
|
127
131
|
fit?: FrameFit;
|
|
132
|
+
frame?: DisplayedFrameUt | null;
|
|
128
133
|
}): import("react").JSX.Element;
|
package/dist/AnchorMarkers.js
CHANGED
|
@@ -135,9 +135,14 @@ export function useDisplayedFrame(enabled, flightId, video) {
|
|
|
135
135
|
}, [client, enabled, flightId, video]);
|
|
136
136
|
return frame;
|
|
137
137
|
}
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Self-subscribing markers for a feed: watches its displayed frames and
|
|
140
|
+
* draws their anchors, or draws `frame`'s while a host's own delayed
|
|
141
|
+
* playout gives it.
|
|
142
|
+
*/
|
|
143
|
+
export function LiveAnchorMarkers({ flightId, video, overlay, fit, frame: given, }) {
|
|
144
|
+
const watched = useDisplayedFrame(!given, flightId, video);
|
|
145
|
+
const frame = given ?? watched;
|
|
141
146
|
return _jsx(AnchorMarkers, { frame: frame, video: video, overlay: overlay, fit: fit });
|
|
142
147
|
}
|
|
143
148
|
const MarkerSvg = styled.svg `
|
package/dist/CameraFeed.d.ts
CHANGED
|
@@ -28,7 +28,11 @@ export interface CameraFeedProps {
|
|
|
28
28
|
client?: KerbcastClient;
|
|
29
29
|
/**
|
|
30
30
|
* KSP `Part.flightID` of the camera to display. `null` triggers
|
|
31
|
-
* auto-selection: the first live camera in the registry is latched.
|
|
31
|
+
* auto-selection: the first live camera in the registry is latched. A
|
|
32
|
+
* displayed camera that drops out of the list stays on screen, with its
|
|
33
|
+
* last known state, for `KERBCAST_RELEASE_GRACE_MS` before the feed falls
|
|
34
|
+
* back, so a momentary gap does not restart its stream. A recording of
|
|
35
|
+
* that camera still ends at once (`camera-gone`).
|
|
32
36
|
*/
|
|
33
37
|
flightId: number | null;
|
|
34
38
|
/**
|
|
@@ -218,19 +222,22 @@ export interface CameraFeedProps {
|
|
|
218
222
|
noSlotLabel?: string | null;
|
|
219
223
|
/**
|
|
220
224
|
* Mission UT the displayed video was captured at, for a host running its
|
|
221
|
-
* own delayed playout
|
|
222
|
-
*
|
|
223
|
-
* the
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
225
|
+
* own delayed playout: the capture UT of the frame its buffer last
|
|
226
|
+
* released. Picks which command-track transition is in force and ages the
|
|
227
|
+
* readout, and the brackets, readout and anchor markers come from the
|
|
228
|
+
* newest frame at or before it (`KerbcastClient.frameAt`), so they never
|
|
229
|
+
* run ahead of the picture. Before any frame that old is known they behave
|
|
230
|
+
* as without it. Defaults to the displayed frame's own capture UT where the
|
|
231
|
+
* browser reports frame RTP timestamps, else the client's capture clock
|
|
232
|
+
* interpolated to now.
|
|
227
233
|
*/
|
|
228
234
|
displayUt?: number;
|
|
229
235
|
/**
|
|
230
236
|
* The frame a host's own delayed playout is showing, as
|
|
231
237
|
* `watchDisplayedFrames` reported it when it arrived. Wins over
|
|
232
|
-
* `displayUt`: its `ut` picks the transition in force
|
|
233
|
-
* draws the brackets and readout
|
|
238
|
+
* `displayUt`: its `ut` picks the transition in force, its `command`
|
|
239
|
+
* draws the brackets and readout and its `anchors` the markers, so they
|
|
240
|
+
* never run ahead of the picture.
|
|
234
241
|
*/
|
|
235
242
|
displayFrame?: DisplayedFrameUt | null;
|
|
236
243
|
/**
|
package/dist/CameraFeed.js
CHANGED
|
@@ -4,7 +4,7 @@ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo
|
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import styled, { css } from "styled-components";
|
|
6
6
|
import { buildCameraLabeler } from "./cameraLabels";
|
|
7
|
-
import { KerbcastProvider, useKerbcastClient } from "./context";
|
|
7
|
+
import { KERBCAST_RELEASE_GRACE_MS, KerbcastProvider, useKerbcastClient } from "./context";
|
|
8
8
|
import { ActionBarRow, FeedActionBar, OverlayIconButton, feedActionToEntry, } from "./FeedActionBar";
|
|
9
9
|
import { CommandBlank, LiveCommandBrackets } from "./CommandBrackets";
|
|
10
10
|
import { LiveAnchorMarkers } from "./AnchorMarkers";
|
|
@@ -112,23 +112,47 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
112
112
|
//
|
|
113
113
|
// Resolution order:
|
|
114
114
|
// 1. Explicit pick, if still present in the list (destroyed or not).
|
|
115
|
-
// 2.
|
|
115
|
+
// 2. Hold: the displayed camera, briefly missing from the list.
|
|
116
|
+
// 3. Auto: latch the currently-displayed camera. Keep showing it even if
|
|
116
117
|
// destroyed, but only while no other live camera exists (destroyed
|
|
117
118
|
// tombstones never leave the list on their own, so a destroyed latch
|
|
118
119
|
// would otherwise never release). Once a live camera is available,
|
|
119
120
|
// release the latch and auto-pick it.
|
|
120
|
-
//
|
|
121
|
+
// 4. Fresh auto-pick: prefer the first live camera; fall back to first
|
|
121
122
|
// overall only when every camera is destroyed.
|
|
122
123
|
// -------------------------------------------------------------------------
|
|
123
124
|
const displayedRef = useRef(null);
|
|
125
|
+
const requestedRef = useRef(requestedFlightId);
|
|
126
|
+
const lastCameraRef = useRef(null);
|
|
127
|
+
/* The camera whose hold ran out, so it is let go until it is listed again. */
|
|
128
|
+
const [expiredHold, setExpiredHold] = useState(null);
|
|
129
|
+
/*
|
|
130
|
+
* The displayed camera dropping out of the list for an update or two (an
|
|
131
|
+
* empty snapshot at launch or revert, a host filter that skips it once)
|
|
132
|
+
* would otherwise switch the feed away and back, restarting the stream
|
|
133
|
+
* both times. Keep showing it, with its last known state, for
|
|
134
|
+
* KERBCAST_RELEASE_GRACE_MS. A new `flightId` prop is a real choice and
|
|
135
|
+
* switches at once, and a requested camera that is listed wins over a
|
|
136
|
+
* held one. The hold is the feed's alone: a recording of the camera
|
|
137
|
+
* still ends at once as camera-gone.
|
|
138
|
+
*/
|
|
124
139
|
const requestedStillPresent = requestedFlightId !== null &&
|
|
125
140
|
cameras.some((c) => c.flightId === requestedFlightId);
|
|
141
|
+
const displayed = displayedRef.current;
|
|
142
|
+
const holding = !requestedStillPresent &&
|
|
143
|
+
displayed !== null &&
|
|
144
|
+
expiredHold !== displayed &&
|
|
145
|
+
requestedFlightId === requestedRef.current &&
|
|
146
|
+
!cameras.some((c) => c.flightId === displayed);
|
|
126
147
|
let flightId;
|
|
127
148
|
if (requestedStillPresent) {
|
|
128
149
|
flightId = requestedFlightId;
|
|
129
150
|
}
|
|
151
|
+
else if (holding) {
|
|
152
|
+
flightId = displayed;
|
|
153
|
+
}
|
|
130
154
|
else {
|
|
131
|
-
const latched =
|
|
155
|
+
const latched = displayed;
|
|
132
156
|
const latchedCamera = latched !== null ? cameras.find((c) => c.flightId === latched) : undefined;
|
|
133
157
|
const anyLive = cameras.some((c) => !isCameraDestroyed(c));
|
|
134
158
|
const latchedPresent = latchedCamera !== undefined &&
|
|
@@ -139,9 +163,31 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
139
163
|
cameras[0]?.flightId ??
|
|
140
164
|
null);
|
|
141
165
|
}
|
|
142
|
-
const
|
|
166
|
+
const listedCamera = flightId !== null
|
|
143
167
|
? (cameras.find((c) => c.flightId === flightId) ?? null)
|
|
144
168
|
: null;
|
|
169
|
+
const camera = holding && lastCameraRef.current?.flightId === flightId
|
|
170
|
+
? lastCameraRef.current
|
|
171
|
+
: listedCamera;
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (listedCamera)
|
|
174
|
+
lastCameraRef.current = listedCamera;
|
|
175
|
+
}, [listedCamera]);
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (!holding || displayed === null)
|
|
178
|
+
return;
|
|
179
|
+
const timer = setTimeout(() => setExpiredHold(displayed), KERBCAST_RELEASE_GRACE_MS);
|
|
180
|
+
return () => clearTimeout(timer);
|
|
181
|
+
}, [holding, displayed]);
|
|
182
|
+
/* Listed again, so a later gap gets a hold of its own. */
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (expiredHold !== null && cameras.some((c) => c.flightId === expiredHold)) {
|
|
185
|
+
setExpiredHold(null);
|
|
186
|
+
}
|
|
187
|
+
}, [expiredHold, cameras]);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
requestedRef.current = requestedFlightId;
|
|
190
|
+
}, [requestedFlightId]);
|
|
145
191
|
// Latest onDisplayedCameraChange, held in a ref so the commit effect below
|
|
146
192
|
// fires only on a resolved-flightId change, not on every render when the
|
|
147
193
|
// consumer passes an inline callback.
|
|
@@ -307,7 +353,10 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
307
353
|
const commandCamera = camera?.kind === CameraKind.Command && !isDestroyed ? camera : null;
|
|
308
354
|
const isCommandFeed = commandCamera !== null;
|
|
309
355
|
const commandTrack = commandCamera?.commandTrack ?? null;
|
|
310
|
-
|
|
356
|
+
/* A host on its own delay names the frame it shows, or its UT for the client to look the frame up by. */
|
|
357
|
+
const hostFrame = displayFrame ??
|
|
358
|
+
(displayUtProp !== undefined && flightId !== null ? client.frameAt(flightId, displayUtProp) : null);
|
|
359
|
+
const commandDisplayUt = useCommandDisplayUt(commandTrack !== null, displayFrame ? displayFrame.ut : displayUtProp, commandTrack?.events, commandCamera?.flightId ?? null, videoEl, hostFrame?.command);
|
|
311
360
|
const commandState = commandTrack
|
|
312
361
|
? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut, commandDisplayUt?.command)
|
|
313
362
|
: null;
|
|
@@ -777,7 +826,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
777
826
|
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));
|
|
778
827
|
}) }), menuPortalRoot()), commandState &&
|
|
779
828
|
!outOfFlight &&
|
|
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) => {
|
|
829
|
+
(_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, frame: hostFrame, 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, frame: hostFrame })), 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) => {
|
|
781
830
|
if (e.detail === 0)
|
|
782
831
|
controllerRef.current?.nudgeZoom(-1);
|
|
783
832
|
}, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type EffectiveCommandTrack, type FrameAnchor, type FrameUtSource } from "@ksp-gonogo/kerbcast";
|
|
1
|
+
import { type DisplayedFrameUt, 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: {
|
|
@@ -150,8 +150,10 @@ export declare function CommandBrackets({ state, displayUt, utSource, video, fal
|
|
|
150
150
|
* `flightId` that `video` presents. Re-renders at the frame rate, which is
|
|
151
151
|
* why it is its own component.
|
|
152
152
|
*/
|
|
153
|
-
export declare function LiveCommandBrackets({ flightId, ...props }: CommandBracketsProps & {
|
|
153
|
+
export declare function LiveCommandBrackets({ flightId, frame: given, ...props }: CommandBracketsProps & {
|
|
154
154
|
flightId: number | null;
|
|
155
|
+
/** The frame a host's own delayed playout shows; while set, the video's own frames are not watched. */
|
|
156
|
+
frame?: DisplayedFrameUt | null;
|
|
155
157
|
}): import("react").JSX.Element;
|
|
156
158
|
/** Reason a command feed is blanked, centred on black over the feed. */
|
|
157
159
|
export declare function CommandBlank({ reason }: {
|
package/dist/CommandBrackets.js
CHANGED
|
@@ -166,8 +166,9 @@ export function CommandBrackets({ state, displayUt, utSource, video, fallbackSiz
|
|
|
166
166
|
* `flightId` that `video` presents. Re-renders at the frame rate, which is
|
|
167
167
|
* why it is its own component.
|
|
168
168
|
*/
|
|
169
|
-
export function LiveCommandBrackets({ flightId, ...props }) {
|
|
170
|
-
const
|
|
169
|
+
export function LiveCommandBrackets({ flightId, frame: given, ...props }) {
|
|
170
|
+
const watched = useDisplayedFrame(!given, flightId, props.video);
|
|
171
|
+
const frame = given ?? watched;
|
|
171
172
|
const subject = findAnchor(frame?.anchors, COMMAND_SUBJECT_ANCHOR_ID, KERBCAST_ANCHOR_OWNER);
|
|
172
173
|
const aim = findAnchor(frame?.anchors, COMMAND_AIM_ANCHOR_ID, KERBCAST_ANCHOR_OWNER);
|
|
173
174
|
const size = frame?.frameWidth && frame.frameHeight
|
package/dist/context.d.ts
CHANGED
|
@@ -9,8 +9,27 @@ export interface KerbcastSubscriptions {
|
|
|
9
9
|
acquire(flightId: number): void;
|
|
10
10
|
release(flightId: number): void;
|
|
11
11
|
}
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* How long a camera's slot is kept after its last holder releases it, in
|
|
14
|
+
* milliseconds. A feed that remounts inside this window (a layout change, a
|
|
15
|
+
* scene switch re-rendering the tree) takes the slot back with its stream
|
|
16
|
+
* still flowing, instead of freeing it and waiting for a fresh keyframe.
|
|
17
|
+
*/
|
|
18
|
+
export declare const KERBCAST_RELEASE_GRACE_MS = 2000;
|
|
19
|
+
export interface ClientSubscriptionsOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Delay before a released camera is unsubscribed. Defaults to
|
|
22
|
+
* {@link KERBCAST_RELEASE_GRACE_MS}; 0 unsubscribes at once.
|
|
23
|
+
*/
|
|
24
|
+
releaseGraceMs?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Build the default refcounted subscriptions implementation for a client.
|
|
28
|
+
* When the last holder of a camera releases it the unsubscribe waits
|
|
29
|
+
* `releaseGraceMs`, and an acquire inside that window keeps the slot with no
|
|
30
|
+
* message sent either way.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createClientSubscriptions(client: KerbcastClient, options?: ClientSubscriptionsOptions): KerbcastSubscriptions;
|
|
14
33
|
/**
|
|
15
34
|
* Collects each mounted feed's self-measured display size and drives the
|
|
16
35
|
* client's per-consumer `reportDisplaySize`. Multiple feeds can show the SAME
|
|
@@ -36,13 +55,18 @@ export interface KerbcastProviderProps {
|
|
|
36
55
|
subscriptions?: KerbcastSubscriptions;
|
|
37
56
|
/** Override the display-size registry. Defaults to a per-flightId MAX impl. */
|
|
38
57
|
displaySizes?: KerbcastDisplaySizes;
|
|
58
|
+
/**
|
|
59
|
+
* Release grace for the default subscriptions, in milliseconds. Defaults to
|
|
60
|
+
* {@link KERBCAST_RELEASE_GRACE_MS}. Ignored when `subscriptions` is given.
|
|
61
|
+
*/
|
|
62
|
+
releaseGraceMs?: number;
|
|
39
63
|
children: ReactNode;
|
|
40
64
|
}
|
|
41
65
|
/**
|
|
42
66
|
* Provide a `KerbcastClient` (and optional subscriptions override) to the
|
|
43
67
|
* component subtree. Hooks and `CameraFeed` read both from this context.
|
|
44
68
|
*/
|
|
45
|
-
export declare function KerbcastProvider({ client, subscriptions: subscriptionsProp, displaySizes: displaySizesProp, children, }: KerbcastProviderProps): React.JSX.Element;
|
|
69
|
+
export declare function KerbcastProvider({ client, subscriptions: subscriptionsProp, displaySizes: displaySizesProp, releaseGraceMs, children, }: KerbcastProviderProps): React.JSX.Element;
|
|
46
70
|
/**
|
|
47
71
|
* Return the `KerbcastClient` from the nearest `KerbcastProvider`.
|
|
48
72
|
* Throws if called outside a provider.
|
package/dist/context.js
CHANGED
|
@@ -1,8 +1,69 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, useContext, useMemo
|
|
3
|
-
/**
|
|
4
|
-
|
|
2
|
+
import { createContext, useContext, useMemo } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* How long a camera's slot is kept after its last holder releases it, in
|
|
5
|
+
* milliseconds. A feed that remounts inside this window (a layout change, a
|
|
6
|
+
* scene switch re-rendering the tree) takes the slot back with its stream
|
|
7
|
+
* still flowing, instead of freeing it and waiting for a fresh keyframe.
|
|
8
|
+
*/
|
|
9
|
+
export const KERBCAST_RELEASE_GRACE_MS = 2000;
|
|
10
|
+
/**
|
|
11
|
+
* Build the default refcounted subscriptions implementation for a client.
|
|
12
|
+
* When the last holder of a camera releases it the unsubscribe waits
|
|
13
|
+
* `releaseGraceMs`, and an acquire inside that window keeps the slot with no
|
|
14
|
+
* message sent either way.
|
|
15
|
+
*/
|
|
16
|
+
export function createClientSubscriptions(client, options = {}) {
|
|
17
|
+
return withGrace(createSubscriptionsCore(client), options.releaseGraceMs ?? KERBCAST_RELEASE_GRACE_MS);
|
|
18
|
+
}
|
|
19
|
+
function withGrace(core, graceMs) {
|
|
20
|
+
return {
|
|
21
|
+
acquire: (flightId) => core.acquire(flightId),
|
|
22
|
+
release: (flightId) => core.release(flightId, graceMs),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function createSubscriptionsCore(client) {
|
|
5
26
|
const refcounts = new Map();
|
|
27
|
+
const pendingRelease = new Map();
|
|
28
|
+
const stopWatchingSlot = new Map();
|
|
29
|
+
function unsubscribeNow(flightId) {
|
|
30
|
+
stopWatchingSlot.get(flightId)?.();
|
|
31
|
+
stopWatchingSlot.delete(flightId);
|
|
32
|
+
client.unsubscribe(flightId).catch(() => { });
|
|
33
|
+
}
|
|
34
|
+
/*
|
|
35
|
+
* A slot kept for a feed that might come back is worth less than one a
|
|
36
|
+
* mounted feed is waiting for. While any held camera waits for a slot,
|
|
37
|
+
* releases skip the grace, and a camera that starts waiting ends every
|
|
38
|
+
* grace under way so the slots it frees can carry it.
|
|
39
|
+
*/
|
|
40
|
+
function slotWanted() {
|
|
41
|
+
for (const flightId of refcounts.keys()) {
|
|
42
|
+
if (client.camera(flightId).waitingForSlot)
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
function watchSlot(flightId) {
|
|
48
|
+
if (stopWatchingSlot.has(flightId))
|
|
49
|
+
return;
|
|
50
|
+
stopWatchingSlot.set(flightId, client.camera(flightId).on("waiting", (waiting) => {
|
|
51
|
+
if (waiting)
|
|
52
|
+
flushPendingReleases();
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
/*
|
|
56
|
+
* A connection that ends takes this client's slots with it, so a camera
|
|
57
|
+
* waiting out its grace has nothing left to keep. Unsubscribe it now, which
|
|
58
|
+
* also clears the client's own record that it wants the camera.
|
|
59
|
+
*/
|
|
60
|
+
function flushPendingReleases() {
|
|
61
|
+
for (const [flightId, timer] of pendingRelease) {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
unsubscribeNow(flightId);
|
|
64
|
+
}
|
|
65
|
+
pendingRelease.clear();
|
|
66
|
+
}
|
|
6
67
|
/*
|
|
7
68
|
* A reconnect clears every slot binding on the client and the sidecar starts
|
|
8
69
|
* the new peer empty, so re-subscribe each camera still held. Listeners fire
|
|
@@ -10,29 +71,81 @@ export function createClientSubscriptions(client) {
|
|
|
10
71
|
* registry, so subscriptions go out first. The sidecar treats a repeat
|
|
11
72
|
* subscribe for a bound camera as a re-announce, and a rejection (for
|
|
12
73
|
* example a full slot pool) is swallowed like every other subscribe here.
|
|
74
|
+
* A camera only waiting out its grace is not held, so it is dropped rather
|
|
75
|
+
* than bound again on the new peer.
|
|
13
76
|
*/
|
|
14
77
|
client.on("control-open", () => {
|
|
78
|
+
flushPendingReleases();
|
|
15
79
|
for (const flightId of refcounts.keys()) {
|
|
16
80
|
client.subscribe(flightId).catch(() => { });
|
|
17
81
|
}
|
|
18
82
|
});
|
|
83
|
+
client.on("state-change", (state) => {
|
|
84
|
+
if (state === "disconnected" || state === "failed")
|
|
85
|
+
flushPendingReleases();
|
|
86
|
+
});
|
|
87
|
+
/*
|
|
88
|
+
* A fresh subscribe made while a release is pending waits for a microtask
|
|
89
|
+
* and goes out after every release still pending is flushed. React runs
|
|
90
|
+
* every cleanup and effect of one commit in a single task, so a feed that
|
|
91
|
+
* remounts takes its camera back first and keeps the grace, while a feed
|
|
92
|
+
* switching cameras frees its old slot on the wire before asking for the
|
|
93
|
+
* new one. Otherwise a full pool would refuse the new camera, or grow by a
|
|
94
|
+
* re-offer, while the old one sat in its grace. With nothing pending the
|
|
95
|
+
* subscribe goes out at once.
|
|
96
|
+
*/
|
|
97
|
+
const toSubscribe = new Set();
|
|
98
|
+
function subscribeQueued() {
|
|
99
|
+
flushPendingReleases();
|
|
100
|
+
for (const flightId of toSubscribe) {
|
|
101
|
+
client.subscribe(flightId).catch(() => { });
|
|
102
|
+
}
|
|
103
|
+
toSubscribe.clear();
|
|
104
|
+
}
|
|
19
105
|
return {
|
|
20
106
|
acquire(flightId) {
|
|
21
107
|
const count = (refcounts.get(flightId) ?? 0) + 1;
|
|
22
108
|
refcounts.set(flightId, count);
|
|
109
|
+
const pending = pendingRelease.get(flightId);
|
|
110
|
+
if (pending !== undefined) {
|
|
111
|
+
clearTimeout(pending);
|
|
112
|
+
pendingRelease.delete(flightId);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
23
115
|
if (count === 1) {
|
|
24
|
-
|
|
116
|
+
watchSlot(flightId);
|
|
117
|
+
if (pendingRelease.size === 0 && toSubscribe.size === 0) {
|
|
118
|
+
client.subscribe(flightId).catch(() => { });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (toSubscribe.size === 0)
|
|
122
|
+
queueMicrotask(subscribeQueued);
|
|
123
|
+
toSubscribe.add(flightId);
|
|
25
124
|
}
|
|
26
125
|
},
|
|
27
|
-
release(flightId) {
|
|
126
|
+
release(flightId, graceMs) {
|
|
28
127
|
const count = (refcounts.get(flightId) ?? 1) - 1;
|
|
29
|
-
if (count
|
|
30
|
-
refcounts.delete(flightId);
|
|
31
|
-
client.unsubscribe(flightId).catch(() => { });
|
|
32
|
-
}
|
|
33
|
-
else {
|
|
128
|
+
if (count > 0) {
|
|
34
129
|
refcounts.set(flightId, count);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
refcounts.delete(flightId);
|
|
133
|
+
if (toSubscribe.delete(flightId)) {
|
|
134
|
+
stopWatchingSlot.get(flightId)?.();
|
|
135
|
+
stopWatchingSlot.delete(flightId);
|
|
136
|
+
return;
|
|
35
137
|
}
|
|
138
|
+
if (pendingRelease.has(flightId))
|
|
139
|
+
return;
|
|
140
|
+
/* A camera still waiting for a slot has no stream to keep. */
|
|
141
|
+
if (graceMs <= 0 || client.camera(flightId).waitingForSlot || slotWanted()) {
|
|
142
|
+
unsubscribeNow(flightId);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
pendingRelease.set(flightId, setTimeout(() => {
|
|
146
|
+
pendingRelease.delete(flightId);
|
|
147
|
+
unsubscribeNow(flightId);
|
|
148
|
+
}, graceMs));
|
|
36
149
|
},
|
|
37
150
|
};
|
|
38
151
|
}
|
|
@@ -97,29 +210,41 @@ export function createClientDisplaySizes(client) {
|
|
|
97
210
|
};
|
|
98
211
|
}
|
|
99
212
|
const KerbcastContext = createContext(null);
|
|
213
|
+
const clientDefaults = new WeakMap();
|
|
214
|
+
function defaultsOf(client) {
|
|
215
|
+
let defaults = clientDefaults.get(client);
|
|
216
|
+
if (!defaults) {
|
|
217
|
+
defaults = { core: null, subscriptions: new Map(), displaySizes: null };
|
|
218
|
+
clientDefaults.set(client, defaults);
|
|
219
|
+
}
|
|
220
|
+
return defaults;
|
|
221
|
+
}
|
|
222
|
+
function defaultSubscriptions(client, releaseGraceMs) {
|
|
223
|
+
const defaults = defaultsOf(client);
|
|
224
|
+
let subscriptions = defaults.subscriptions.get(releaseGraceMs);
|
|
225
|
+
if (!subscriptions) {
|
|
226
|
+
defaults.core ?? (defaults.core = createSubscriptionsCore(client));
|
|
227
|
+
subscriptions = withGrace(defaults.core, releaseGraceMs);
|
|
228
|
+
defaults.subscriptions.set(releaseGraceMs, subscriptions);
|
|
229
|
+
}
|
|
230
|
+
return subscriptions;
|
|
231
|
+
}
|
|
232
|
+
function defaultDisplaySizes(client) {
|
|
233
|
+
const defaults = defaultsOf(client);
|
|
234
|
+
defaults.displaySizes ?? (defaults.displaySizes = createClientDisplaySizes(client));
|
|
235
|
+
return defaults.displaySizes;
|
|
236
|
+
}
|
|
100
237
|
/**
|
|
101
238
|
* Provide a `KerbcastClient` (and optional subscriptions override) to the
|
|
102
239
|
* component subtree. Hooks and `CameraFeed` read both from this context.
|
|
103
240
|
*/
|
|
104
|
-
export function KerbcastProvider({ client, subscriptions: subscriptionsProp, displaySizes: displaySizesProp, children, }) {
|
|
105
|
-
// Create default subscriptions once per client. A ref so the object is
|
|
106
|
-
// stable across renders even if the parent re-renders.
|
|
107
|
-
const defaultSubsRef = useRef(null);
|
|
108
|
-
if (defaultSubsRef.current === null) {
|
|
109
|
-
defaultSubsRef.current = createClientSubscriptions(client);
|
|
110
|
-
}
|
|
111
|
-
// Same for the display-size registry: one per client, stable across renders.
|
|
112
|
-
const defaultSizesRef = useRef(null);
|
|
113
|
-
if (defaultSizesRef.current === null) {
|
|
114
|
-
defaultSizesRef.current = createClientDisplaySizes(client);
|
|
115
|
-
}
|
|
241
|
+
export function KerbcastProvider({ client, subscriptions: subscriptionsProp, displaySizes: displaySizesProp, releaseGraceMs = KERBCAST_RELEASE_GRACE_MS, children, }) {
|
|
116
242
|
const value = useMemo(() => ({
|
|
117
243
|
client,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
[client, subscriptionsProp, displaySizesProp]);
|
|
244
|
+
/* Subscriptions first: their reconnect listener must run before the registry's. */
|
|
245
|
+
subscriptions: subscriptionsProp ?? defaultSubscriptions(client, releaseGraceMs),
|
|
246
|
+
displaySizes: displaySizesProp ?? defaultDisplaySizes(client),
|
|
247
|
+
}), [client, subscriptionsProp, displaySizesProp, releaseGraceMs]);
|
|
123
248
|
return (_jsx(KerbcastContext.Provider, { value: value, children: children }));
|
|
124
249
|
}
|
|
125
250
|
// ---------------------------------------------------------------------------
|
|
@@ -27,8 +27,9 @@ export declare function commandShownKey(c: FrameCommandState | undefined): strin
|
|
|
27
27
|
* displayed UT reaches the next of `events`, so a state change lands on its
|
|
28
28
|
* frame rather than on the next tick. The displayed frame's own command
|
|
29
29
|
* state comes back as `command`, re-rendering when what it shows changes:
|
|
30
|
-
* an exact frame's, an estimated one's (which lags the picture
|
|
31
|
-
*
|
|
30
|
+
* an exact frame's, an estimated one's (which lags the picture while the
|
|
31
|
+
* element plays the stream as it arrives; its own UT is used too, so events
|
|
32
|
+
* and lists stay on that frame),
|
|
32
33
|
* or with an override the host's own `overrideCommand`. `null` when
|
|
33
34
|
* nothing is known.
|
|
34
35
|
*/
|
|
@@ -61,8 +61,9 @@ export function commandShownKey(c) {
|
|
|
61
61
|
* displayed UT reaches the next of `events`, so a state change lands on its
|
|
62
62
|
* frame rather than on the next tick. The displayed frame's own command
|
|
63
63
|
* state comes back as `command`, re-rendering when what it shows changes:
|
|
64
|
-
* an exact frame's, an estimated one's (which lags the picture
|
|
65
|
-
*
|
|
64
|
+
* an exact frame's, an estimated one's (which lags the picture while the
|
|
65
|
+
* element plays the stream as it arrives; its own UT is used too, so events
|
|
66
|
+
* and lists stay on that frame),
|
|
66
67
|
* or with an override the host's own `overrideCommand`. `null` when
|
|
67
68
|
* nothing is known.
|
|
68
69
|
*/
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* While mounted with a non-null `flightId`, acquires a subscription slot via
|
|
7
7
|
* the context `KerbcastSubscriptions`. Releases the slot when the component
|
|
8
|
-
* unmounts or the `flightId` changes
|
|
8
|
+
* unmounts or the `flightId` changes; the default subscriptions keep it for
|
|
9
|
+
* `KERBCAST_RELEASE_GRACE_MS`, so a remount in that time starts on the
|
|
10
|
+
* stream already flowing.
|
|
9
11
|
*/
|
|
10
12
|
export declare function useKerbcastStream(flightId: number | null): MediaStream | null;
|
|
@@ -7,7 +7,9 @@ import { useKerbcastClient, useKerbcastSubscriptions } from "../context";
|
|
|
7
7
|
*
|
|
8
8
|
* While mounted with a non-null `flightId`, acquires a subscription slot via
|
|
9
9
|
* the context `KerbcastSubscriptions`. Releases the slot when the component
|
|
10
|
-
* unmounts or the `flightId` changes
|
|
10
|
+
* unmounts or the `flightId` changes; the default subscriptions keep it for
|
|
11
|
+
* `KERBCAST_RELEASE_GRACE_MS`, so a remount in that time starts on the
|
|
12
|
+
* stream already flowing.
|
|
11
13
|
*/
|
|
12
14
|
export function useKerbcastStream(flightId) {
|
|
13
15
|
const client = useKerbcastClient();
|
package/dist/index.d.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* is a peer to @ksp-gonogo/kerbcast (the core SDK) and requires React 18+
|
|
6
6
|
* and styled-components 6+ as peer dependencies.
|
|
7
7
|
*/
|
|
8
|
-
export { KerbcastProvider, createClientSubscriptions, createClientDisplaySizes, useKerbcastClient, useKerbcastSubscriptions, useKerbcastDisplaySizes, } from "./context";
|
|
9
|
-
export type { KerbcastProviderProps, KerbcastSubscriptions, KerbcastDisplaySizes, } from "./context";
|
|
8
|
+
export { KerbcastProvider, createClientSubscriptions, createClientDisplaySizes, KERBCAST_RELEASE_GRACE_MS, useKerbcastClient, useKerbcastSubscriptions, useKerbcastDisplaySizes, } from "./context";
|
|
9
|
+
export type { KerbcastProviderProps, KerbcastSubscriptions, ClientSubscriptionsOptions, KerbcastDisplaySizes, } from "./context";
|
|
10
10
|
export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
11
11
|
export { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
12
12
|
export { useKerbcastClock } from "./hooks/useKerbcastClock";
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* and styled-components 6+ as peer dependencies.
|
|
7
7
|
*/
|
|
8
8
|
// Context and provider
|
|
9
|
-
export { KerbcastProvider, createClientSubscriptions, createClientDisplaySizes, useKerbcastClient, useKerbcastSubscriptions, useKerbcastDisplaySizes, } from "./context";
|
|
9
|
+
export { KerbcastProvider, createClientSubscriptions, createClientDisplaySizes, KERBCAST_RELEASE_GRACE_MS, useKerbcastClient, useKerbcastSubscriptions, useKerbcastDisplaySizes, } from "./context";
|
|
10
10
|
// Hooks
|
|
11
11
|
export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
12
12
|
export { useKerbcastStream } from "./hooks/useKerbcastStream";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ksp-gonogo/kerbcast-react",
|
|
3
|
-
"version": "1.12.0-rc.
|
|
3
|
+
"version": "1.12.0-rc.4",
|
|
4
4
|
"description": "React components and hooks for kerbcast camera feeds.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jon Pepler",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"access": "public"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@ksp-gonogo/kerbcast": "^1.12.0-rc.
|
|
48
|
+
"@ksp-gonogo/kerbcast": "^1.12.0-rc.4"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"react": "^18 || ^19",
|