@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 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.
@@ -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
- /** Self-subscribing markers for a feed: watches its displayed frames and draws their anchors. */
123
- export declare function LiveAnchorMarkers({ flightId, video, overlay, fit, }: {
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;
@@ -135,9 +135,14 @@ export function useDisplayedFrame(enabled, flightId, video) {
135
135
  }, [client, enabled, flightId, video]);
136
136
  return frame;
137
137
  }
138
- /** Self-subscribing markers for a feed: watches its displayed frames and draws their anchors. */
139
- export function LiveAnchorMarkers({ flightId, video, overlay, fit, }) {
140
- const frame = useDisplayedFrame(true, flightId, video);
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 `
@@ -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. Picks which command-track transition is in force and
222
- * ages the readout. Defaults to the displayed frame's own capture UT where
223
- * the browser reports frame RTP timestamps, else the client's capture
224
- * clock interpolated to now. While a command lock is held the brackets and
225
- * readout come from the displayed frame's own state, which a UT alone does
226
- * not carry: such a host passes `displayFrame` instead.
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 and its `command`
233
- * draws the brackets and readout, so they never run ahead of the picture.
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
  /**
@@ -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. Auto: latch the currently-displayed camera. Keep showing it even if
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
- // 3. Fresh auto-pick: prefer the first live camera; fall back to first
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 = displayedRef.current;
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 camera = flightId !== null
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
- const commandDisplayUt = useCommandDisplayUt(commandTrack !== null, displayFrame ? displayFrame.ut : displayUtProp, commandTrack?.events, commandCamera?.flightId ?? null, videoEl, displayFrame?.command);
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 }: {
@@ -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 frame = useDisplayedFrame(true, flightId, props.video);
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
- /** Build the default refcounted subscriptions implementation for a client. */
13
- export declare function createClientSubscriptions(client: KerbcastClient): KerbcastSubscriptions;
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, useRef, } from "react";
3
- /** Build the default refcounted subscriptions implementation for a client. */
4
- export function createClientSubscriptions(client) {
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
- client.subscribe(flightId).catch(() => { });
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 <= 0) {
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
- subscriptions: subscriptionsProp ?? defaultSubsRef.current,
119
- displaySizes: displaySizesProp ?? defaultSizesRef.current,
120
- }),
121
- // eslint-disable-next-line react-hooks/exhaustive-deps
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, so is never
31
- * early; its own UT is used too, so events and lists stay on that frame),
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, so is never
65
- * early; its own UT is used too, so events and lists stay on that frame),
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.2",
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.2"
48
+ "@ksp-gonogo/kerbcast": "^1.12.0-rc.4"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": "^18 || ^19",