@ksp-gonogo/kerbcast-react 1.12.0-rc.1 → 1.12.0-rc.3
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 +22 -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 +141 -20
- package/dist/CommandBrackets.d.ts +4 -2
- package/dist/CommandBrackets.js +3 -2
- package/dist/FeedActionBar.d.ts +7 -0
- package/dist/FeedActionBar.js +4 -4
- 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/dist/menuPositioning.d.ts +8 -2
- package/dist/menuPositioning.js +30 -5
- package/package.json +2 -2
- package/src/AnchorMarkers.tsx +9 -2
- package/src/CameraFeed.test.tsx +939 -10
- package/src/CameraFeed.tsx +173 -29
- package/src/CommandBrackets.tsx +9 -2
- package/src/FeedActionBar.test.ts +22 -0
- package/src/FeedActionBar.tsx +11 -4
- 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/src/menuPositioning.ts +31 -5
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`.
|
|
@@ -82,6 +85,18 @@ cannot loop with the request. A resize within about a second of the stream
|
|
|
82
85
|
changing that matches its new shape counts as following it. Layout changes
|
|
83
86
|
outside that window always report.
|
|
84
87
|
|
|
88
|
+
The feed frame (the element fullscreen targets) carries two attributes for
|
|
89
|
+
hosts and tests:
|
|
90
|
+
|
|
91
|
+
- `data-kc-signal`: `live`, `stalled` (no frame has left the decoder for
|
|
92
|
+
half a second, or none yet since the source attached) or `none` (no
|
|
93
|
+
source). It is measured after any encoded transform the host installs, so
|
|
94
|
+
a transform that holds frames back shows as `stalled` while it holds them.
|
|
95
|
+
The video keeps presenting frames while it shows static, so its frame
|
|
96
|
+
counts cannot tell a picture from static, and inbound-rtp `framesDecoded`
|
|
97
|
+
averaged over seconds misses gaps this short; this attribute can
|
|
98
|
+
- `data-fullscreen`: present while the feed is fullscreen
|
|
99
|
+
|
|
85
100
|
An imperative `CameraFeedHandle` (via `ref`) exposes `stepCamera`,
|
|
86
101
|
`setPanAxis`, `setZoomRate`, `nudgePan`, and `nudgeZoom` for wiring feeds
|
|
87
102
|
to a gamepad or keyboard.
|
|
@@ -172,7 +187,12 @@ quality follows how large it is shown (pass `reportSize={false}` to opt out).
|
|
|
172
187
|
re-rendering as cameras appear, change, or are destroyed.
|
|
173
188
|
- `useKerbcastStream(flightId): MediaStream | null`. The video stream
|
|
174
189
|
for one camera, acquiring and releasing its subscription slot. This is
|
|
175
|
-
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
|
|
176
196
|
- `useKerbcastInFlight(): boolean | undefined`. Whether KSP is in a
|
|
177
197
|
flight scene. `undefined` until the first signal arrives, so you can
|
|
178
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";
|
|
@@ -19,7 +19,7 @@ import { useTargetBracketsPreference } from "./hooks/useTargetBracketsPreference
|
|
|
19
19
|
import { StandbyIcon } from "./StandbyIcon";
|
|
20
20
|
import { isCameraDestroyed } from "./lifecycle";
|
|
21
21
|
import { claimAnchors } from "./sharedAnchors";
|
|
22
|
-
import { usePortalMenu } from "./menuPositioning";
|
|
22
|
+
import { menuPortalRoot, usePortalMenu } from "./menuPositioning";
|
|
23
23
|
import { formatElapsed, nowMs } from "./timing";
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
25
25
|
// Tuning constants
|
|
@@ -44,6 +44,15 @@ function presetDim(operatorDim, scale) {
|
|
|
44
44
|
const v = Math.trunc(operatorDim * scale) & ~1;
|
|
45
45
|
return v < 2 ? 2 : v;
|
|
46
46
|
}
|
|
47
|
+
function videoTrackId(source) {
|
|
48
|
+
if (!source || !("getVideoTracks" in source))
|
|
49
|
+
return undefined;
|
|
50
|
+
return source.getVideoTracks()[0]?.id;
|
|
51
|
+
}
|
|
52
|
+
function sameVideoTrack(a, b) {
|
|
53
|
+
const id = videoTrackId(a);
|
|
54
|
+
return id !== undefined && id === videoTrackId(b);
|
|
55
|
+
}
|
|
47
56
|
function isFullscreenSupported() {
|
|
48
57
|
if (typeof document === "undefined")
|
|
49
58
|
return false;
|
|
@@ -103,23 +112,47 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
103
112
|
//
|
|
104
113
|
// Resolution order:
|
|
105
114
|
// 1. Explicit pick, if still present in the list (destroyed or not).
|
|
106
|
-
// 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
|
|
107
117
|
// destroyed, but only while no other live camera exists (destroyed
|
|
108
118
|
// tombstones never leave the list on their own, so a destroyed latch
|
|
109
119
|
// would otherwise never release). Once a live camera is available,
|
|
110
120
|
// release the latch and auto-pick it.
|
|
111
|
-
//
|
|
121
|
+
// 4. Fresh auto-pick: prefer the first live camera; fall back to first
|
|
112
122
|
// overall only when every camera is destroyed.
|
|
113
123
|
// -------------------------------------------------------------------------
|
|
114
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
|
+
*/
|
|
115
139
|
const requestedStillPresent = requestedFlightId !== null &&
|
|
116
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);
|
|
117
147
|
let flightId;
|
|
118
148
|
if (requestedStillPresent) {
|
|
119
149
|
flightId = requestedFlightId;
|
|
120
150
|
}
|
|
151
|
+
else if (holding) {
|
|
152
|
+
flightId = displayed;
|
|
153
|
+
}
|
|
121
154
|
else {
|
|
122
|
-
const latched =
|
|
155
|
+
const latched = displayed;
|
|
123
156
|
const latchedCamera = latched !== null ? cameras.find((c) => c.flightId === latched) : undefined;
|
|
124
157
|
const anyLive = cameras.some((c) => !isCameraDestroyed(c));
|
|
125
158
|
const latchedPresent = latchedCamera !== undefined &&
|
|
@@ -130,9 +163,31 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
130
163
|
cameras[0]?.flightId ??
|
|
131
164
|
null);
|
|
132
165
|
}
|
|
133
|
-
const
|
|
166
|
+
const listedCamera = flightId !== null
|
|
134
167
|
? (cameras.find((c) => c.flightId === flightId) ?? null)
|
|
135
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]);
|
|
136
191
|
// Latest onDisplayedCameraChange, held in a ref so the commit effect below
|
|
137
192
|
// fires only on a resolved-flightId change, not on every render when the
|
|
138
193
|
// consumer passes an inline callback.
|
|
@@ -161,10 +216,17 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
161
216
|
}, []);
|
|
162
217
|
// The feed frame; fullscreen targets this and ResizeObserver measures it.
|
|
163
218
|
const wrapRef = useRef(null);
|
|
219
|
+
/* Clearing to null on a switch to a camera with no stream yet stops the
|
|
220
|
+
previous camera's last frame lingering. A rebind can hand over a new
|
|
221
|
+
MediaStream around the same track; reassigning that would reset the
|
|
222
|
+
element and flash black, so it is skipped. */
|
|
164
223
|
useEffect(() => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
224
|
+
const video = videoRef.current;
|
|
225
|
+
if (!video || video.srcObject === stream)
|
|
226
|
+
return;
|
|
227
|
+
if (sameVideoTrack(video.srcObject, stream))
|
|
228
|
+
return;
|
|
229
|
+
video.srcObject = stream;
|
|
168
230
|
}, [stream]);
|
|
169
231
|
// -------------------------------------------------------------------------
|
|
170
232
|
// Stall presentation. The static is composited in-stream by the SDK's
|
|
@@ -186,6 +248,19 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
186
248
|
setIsStale(cam.stalled);
|
|
187
249
|
return cam.on("stall", setIsStale);
|
|
188
250
|
}, [client, flightId]);
|
|
251
|
+
const [hasSource, setHasSource] = useState(false);
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
if (flightId === null) {
|
|
254
|
+
setHasSource(false);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const cam = client.camera(flightId);
|
|
258
|
+
setHasSource(cam.hasSource);
|
|
259
|
+
return cam.on("source", setHasSource);
|
|
260
|
+
}, [client, flightId]);
|
|
261
|
+
/* The in-stream static keeps the video element presenting frames, so a
|
|
262
|
+
frame count cannot tell a picture from static; this attribute can. */
|
|
263
|
+
const signal = flightId === null ? undefined : !hasSource ? "none" : isStale ? "stalled" : "live";
|
|
189
264
|
// -------------------------------------------------------------------------
|
|
190
265
|
// Fullscreen + Picture-in-Picture (opt-in, feature-detected)
|
|
191
266
|
// -------------------------------------------------------------------------
|
|
@@ -224,6 +299,21 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
224
299
|
v.removeEventListener("leavepictureinpicture", onLeave);
|
|
225
300
|
};
|
|
226
301
|
}, [pipAvailable, flightId]);
|
|
302
|
+
/* Browsers usually consume Escape to leave fullscreen themselves; this
|
|
303
|
+
covers hosts and embedded views that hand the key to the page instead. */
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
if (!isFullscreen)
|
|
306
|
+
return;
|
|
307
|
+
const onKey = (e) => {
|
|
308
|
+
if (e.key === "Escape" &&
|
|
309
|
+
!e.defaultPrevented &&
|
|
310
|
+
currentFullscreenElement() === wrapRef.current) {
|
|
311
|
+
exitFullscreen();
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
document.addEventListener("keydown", onKey);
|
|
315
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
316
|
+
}, [isFullscreen]);
|
|
227
317
|
const toggleFullscreen = useCallback(() => {
|
|
228
318
|
const el = wrapRef.current;
|
|
229
319
|
if (!el)
|
|
@@ -263,7 +353,10 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
263
353
|
const commandCamera = camera?.kind === CameraKind.Command && !isDestroyed ? camera : null;
|
|
264
354
|
const isCommandFeed = commandCamera !== null;
|
|
265
355
|
const commandTrack = commandCamera?.commandTrack ?? null;
|
|
266
|
-
|
|
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);
|
|
267
360
|
const commandState = commandTrack
|
|
268
361
|
? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut, commandDisplayUt?.command)
|
|
269
362
|
: null;
|
|
@@ -583,7 +676,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
583
676
|
createPortal(_jsx(CameraMenu, { ref: cameraMenu.menuRef, id: menuId, role: "menu", "aria-label": "Camera", style: cameraMenu.position, children: cameras.map((c) => (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": c.flightId === flightId, "$selected": c.flightId === flightId, onClick: () => {
|
|
584
677
|
onSelectCamera?.(c.flightId);
|
|
585
678
|
cameraMenu.close();
|
|
586
|
-
}, children: [cameraLabel(c), " (", c.vesselName, ")", isCameraDestroyed(c) ? " - signal lost" : ""] }, c.flightId))) }),
|
|
679
|
+
}, children: [cameraLabel(c), " (", c.vesselName, ")", isCameraDestroyed(c) ? " - signal lost" : ""] }, c.flightId))) }), menuPortalRoot()), showDebugInfo &&
|
|
587
680
|
(camera ? (_jsxs(TopMeta, { children: [camera.vesselName, " \u00B7 ", camera.renderWidth, "\u00D7", camera.renderHeight, bitrateLabel, adaptiveLabel] })) : (_jsx(TopMeta, { children: "no cameras on this vessel" })))] }));
|
|
588
681
|
/*
|
|
589
682
|
* Built-in action-bar entries, in the bar's natural (fallback) order:
|
|
@@ -677,8 +770,11 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
677
770
|
{
|
|
678
771
|
id: "fullscreen",
|
|
679
772
|
label: fullscreenLabel,
|
|
773
|
+
/* While fullscreen the exit stays inline, never in the ⋮ menu,
|
|
774
|
+
so it can be the one control the Stage keeps visible. */
|
|
680
775
|
stateful: false,
|
|
681
|
-
|
|
776
|
+
inline: isFullscreen,
|
|
777
|
+
render: () => (_jsx(OverlayIconButton, { type: "button", "data-kc-fullscreen-exit": isFullscreen || undefined, "aria-label": fullscreenLabel, "aria-pressed": isFullscreen, title: fullscreenLabel, "$active": isFullscreen, onClick: toggleFullscreen, children: isFullscreen ? _jsx(FullscreenExitIcon, {}) : _jsx(FullscreenEnterIcon, {}) })),
|
|
682
778
|
},
|
|
683
779
|
]
|
|
684
780
|
: []),
|
|
@@ -713,24 +809,24 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
713
809
|
builtInActions);
|
|
714
810
|
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;
|
|
715
811
|
const actionBar = hasActionBar ? (_jsx(FeedActionBar, { leading: stepButtons, entries: entries })) : null;
|
|
716
|
-
return (_jsx(FeedBox, { style: feedShape, children: _jsx(Stage, { ref: wrapRef, "$pinned": chromePinned, children: flightId === null ? (_jsxs(_Fragment, { children: [_jsx(Empty, { children: emptyMessage }), topOverlay, actionBar] })) : (_jsxs(_Fragment, { children: [_jsx(StyledVideo, { ref: attachVideo, autoPlay: true, playsInline: true, muted: true, controls: false, onClick: () => setChromePinned((v) => !v) }), topOverlay, actionBar, qualityAvailable &&
|
|
812
|
+
return (_jsx(FeedBox, { style: feedShape, children: _jsx(Stage, { ref: wrapRef, "$pinned": chromePinned, "$fullscreen": isFullscreen, "data-fullscreen": isFullscreen || undefined, "data-kc-signal": signal, children: flightId === null ? (_jsxs(_Fragment, { children: [_jsx(Empty, { children: emptyMessage }), topOverlay, actionBar] })) : (_jsxs(_Fragment, { children: [_jsx(StyledVideo, { ref: attachVideo, autoPlay: true, playsInline: true, muted: true, controls: false, onClick: () => setChromePinned((v) => !v) }), topOverlay, actionBar, qualityAvailable &&
|
|
717
813
|
qualityMenu.open &&
|
|
718
814
|
qualityMenu.position &&
|
|
719
815
|
camera &&
|
|
720
|
-
createPortal(_jsxs(QualityMenu, { ref: qualityMenu.menuRef, id: qualityMenuId, role: "menu", "aria-label": "Quality", style: qualityMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality == null, "$selected": camera.viewerQuality == null, onClick: () => selectQuality(null), children: "Auto" }), QUALITY_PRESETS.map(({ preset, label, scale }) => (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality === preset, "$selected": camera.viewerQuality === preset, onClick: () => selectQuality(preset), children: [label, " (", presetDim(camera.operatorWidth, scale), "\u00D7", presetDim(camera.operatorHeight, scale), ")"] }, preset))), _jsxs(QualityMeta, { role: "status", children: ["now ", camera.renderWidth, "\u00D7", camera.renderHeight, qualityThrottled ? " · throttled" : ""] })] }),
|
|
816
|
+
createPortal(_jsxs(QualityMenu, { ref: qualityMenu.menuRef, id: qualityMenuId, role: "menu", "aria-label": "Quality", style: qualityMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality == null, "$selected": camera.viewerQuality == null, onClick: () => selectQuality(null), children: "Auto" }), QUALITY_PRESETS.map(({ preset, label, scale }) => (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality === preset, "$selected": camera.viewerQuality === preset, onClick: () => selectQuality(preset), children: [label, " (", presetDim(camera.operatorWidth, scale), "\u00D7", presetDim(camera.operatorHeight, scale), ")"] }, preset))), _jsxs(QualityMeta, { role: "status", children: ["now ", camera.renderWidth, "\u00D7", camera.renderHeight, qualityThrottled ? " · throttled" : ""] })] }), menuPortalRoot()), trackingAvailable &&
|
|
721
817
|
trackingMenu.open &&
|
|
722
818
|
trackingMenu.position &&
|
|
723
819
|
camera &&
|
|
724
|
-
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" })] }),
|
|
820
|
+
createPortal(_jsxs(QualityMenu, { ref: trackingMenu.menuRef, id: trackingMenuId, role: "menu", "aria-label": "Auto-track", style: trackingMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.ActiveVessel, "$selected": trackMode === TrackMode.ActiveVessel, onClick: () => selectTrack(TrackMode.ActiveVessel), children: "Track active vessel" }), _jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.Target, "$selected": trackMode === TrackMode.Target, onClick: () => selectTrack(TrackMode.Target), children: "Track target" })] }), menuPortalRoot()), commandTargetAvailable &&
|
|
725
821
|
commandTargetMenu.open &&
|
|
726
822
|
commandTargetMenu.position &&
|
|
727
823
|
createPortal(_jsx(CameraMenu, { ref: commandTargetMenu.menuRef, id: commandTargetMenuId, role: "menu", "aria-label": "Target", style: commandTargetMenu.position, children: commandTargets.map((t) => {
|
|
728
824
|
const eligible = t.eligibility === CommandTargetEligibility.Ok;
|
|
729
825
|
const current = t.persistentId === commandTargetId;
|
|
730
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));
|
|
731
|
-
}) }),
|
|
827
|
+
}) }), menuPortalRoot()), commandState &&
|
|
732
828
|
!outOfFlight &&
|
|
733
|
-
(_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) => {
|
|
734
830
|
if (e.detail === 0)
|
|
735
831
|
controllerRef.current?.nudgeZoom(-1);
|
|
736
832
|
}, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
|
|
@@ -1033,8 +1129,9 @@ const StepButtons = styled.div `
|
|
|
1033
1129
|
flex-shrink: 0;
|
|
1034
1130
|
`;
|
|
1035
1131
|
const CameraMenu = styled.div `
|
|
1036
|
-
/* Portaled to document.body
|
|
1037
|
-
rect) keeps it clear of the
|
|
1132
|
+
/* Portaled to document.body (the fullscreen element while fullscreen):
|
|
1133
|
+
fixed position (set inline from the trigger rect) keeps it clear of the
|
|
1134
|
+
tile's overflow clipping. */
|
|
1038
1135
|
position: fixed;
|
|
1039
1136
|
z-index: 1000;
|
|
1040
1137
|
max-width: min(260px, calc(100vw - 16px));
|
|
@@ -1090,7 +1187,7 @@ const TopMeta = styled.div `
|
|
|
1090
1187
|
color: rgba(255, 255, 255, 0.78);
|
|
1091
1188
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
1092
1189
|
`;
|
|
1093
|
-
/* The quality picker: portaled
|
|
1190
|
+
/* The quality picker: portaled like CameraMenu, hung from
|
|
1094
1191
|
its action-bar trigger (fixed position set inline from the trigger rect)
|
|
1095
1192
|
so tile clipping cannot cut it off and it survives the hover-revealed
|
|
1096
1193
|
chrome fading while the pointer is over the menu. */
|
|
@@ -1307,6 +1404,30 @@ const Stage = styled(Panel) `
|
|
|
1307
1404
|
opacity: 1;
|
|
1308
1405
|
}
|
|
1309
1406
|
`}
|
|
1407
|
+
|
|
1408
|
+
/* Fullscreen has no hover on touch and may have no pointer at all, so the
|
|
1409
|
+
exit button stays visible in its usual slot while the rest of the bar
|
|
1410
|
+
keeps its hover/focus/tap reveal. */
|
|
1411
|
+
${(p) => p.$fullscreen &&
|
|
1412
|
+
!p.$pinned &&
|
|
1413
|
+
css `
|
|
1414
|
+
${ActionBarRow} {
|
|
1415
|
+
opacity: 1;
|
|
1416
|
+
pointer-events: auto;
|
|
1417
|
+
}
|
|
1418
|
+
${ActionBarRow} > * {
|
|
1419
|
+
transition: opacity 0.15s;
|
|
1420
|
+
}
|
|
1421
|
+
&:not(:hover):not(:focus-within) ${ActionBarRow} > :not([data-kc-fullscreen-exit]) {
|
|
1422
|
+
opacity: 0;
|
|
1423
|
+
pointer-events: none;
|
|
1424
|
+
}
|
|
1425
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1426
|
+
${ActionBarRow} > * {
|
|
1427
|
+
transition: none;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
`}
|
|
1310
1431
|
`;
|
|
1311
1432
|
const Empty = styled.div `
|
|
1312
1433
|
color: #888;
|
|
@@ -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/FeedActionBar.d.ts
CHANGED
|
@@ -68,6 +68,7 @@ export interface FeedAction {
|
|
|
68
68
|
}
|
|
69
69
|
interface Partitionable {
|
|
70
70
|
stateful?: boolean;
|
|
71
|
+
inline?: boolean;
|
|
71
72
|
order?: number;
|
|
72
73
|
pinnedTrailing?: boolean;
|
|
73
74
|
}
|
|
@@ -97,6 +98,12 @@ export interface FeedActionBarEntry {
|
|
|
97
98
|
id: string;
|
|
98
99
|
label: string;
|
|
99
100
|
stateful?: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Keeps a non-stateful entry out of the ⋮ menu without changing the split:
|
|
103
|
+
* it still counts as non-stateful toward the overflow threshold, so the
|
|
104
|
+
* rest of the bar lays out exactly as it would without the flag.
|
|
105
|
+
*/
|
|
106
|
+
inline?: boolean;
|
|
100
107
|
order?: number;
|
|
101
108
|
pinnedTrailing?: boolean;
|
|
102
109
|
render: () => React.ReactNode;
|
package/dist/FeedActionBar.js
CHANGED
|
@@ -29,7 +29,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
29
29
|
import { Fragment, useId } from "react";
|
|
30
30
|
import { createPortal } from "react-dom";
|
|
31
31
|
import styled from "styled-components";
|
|
32
|
-
import { usePortalMenu } from "./menuPositioning";
|
|
32
|
+
import { menuPortalRoot, usePortalMenu } from "./menuPositioning";
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
34
34
|
// Partition logic (pure, unit-tested independently of any rendering)
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
@@ -56,8 +56,8 @@ export function partitionActionBarEntries(entries) {
|
|
|
56
56
|
return { primary: overflowable, overflow: [], pinnedTrailing };
|
|
57
57
|
}
|
|
58
58
|
return {
|
|
59
|
-
primary: overflowable.filter((e) => e.stateful),
|
|
60
|
-
overflow: nonStateful,
|
|
59
|
+
primary: overflowable.filter((e) => e.stateful || e.inline),
|
|
60
|
+
overflow: nonStateful.filter((e) => !e.inline),
|
|
61
61
|
pinnedTrailing,
|
|
62
62
|
};
|
|
63
63
|
}
|
|
@@ -108,7 +108,7 @@ export function FeedActionBar({ leading, entries, className }) {
|
|
|
108
108
|
// the entry's own button click handler, so this closes the
|
|
109
109
|
// menu once the action has actually fired rather than
|
|
110
110
|
// pre-empting it.
|
|
111
|
-
_jsxs(OverflowMenuItem, { onClick: closeOverflowMenu, children: [e.render(), _jsx(OverflowMenuItemLabel, { "aria-hidden": "true", children: e.label })] }, e.id))) }),
|
|
111
|
+
_jsxs(OverflowMenuItem, { onClick: closeOverflowMenu, children: [e.render(), _jsx(OverflowMenuItemLabel, { "aria-hidden": "true", children: e.label })] }, e.id))) }), menuPortalRoot()), pinnedTrailing.map((e) => (_jsx(Fragment, { children: e.render() }, e.id)))] }));
|
|
112
112
|
}
|
|
113
113
|
// ---------------------------------------------------------------------------
|
|
114
114
|
// Icons
|
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.
|