@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 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.
@@ -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";
@@ -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. 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
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
- // 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
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 = displayedRef.current;
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 camera = flightId !== null
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
- if (videoRef.current && stream) {
166
- videoRef.current.srcObject = stream;
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
- 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);
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))) }), document.body), showDebugInfo &&
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
- render: () => (_jsx(OverlayIconButton, { type: "button", "aria-label": fullscreenLabel, "aria-pressed": isFullscreen, title: fullscreenLabel, "$active": isFullscreen, onClick: toggleFullscreen, children: isFullscreen ? _jsx(FullscreenExitIcon, {}) : _jsx(FullscreenEnterIcon, {}) })),
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" : ""] })] }), document.body), trackingAvailable &&
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" })] }), document.body), commandTargetAvailable &&
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
- }) }), document.body), commandState &&
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: fixed position (set inline from the trigger
1037
- rect) keeps it clear of the tile's overflow clipping. */
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 to document.body like CameraMenu, hung from
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 }: {
@@ -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
@@ -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;
@@ -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))) }), document.body), pinnedTrailing.map((e) => (_jsx(Fragment, { children: e.render() }, 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
- /** 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.