@ksp-gonogo/kerbcast-react 1.12.0-rc.1 → 1.12.0-rc.2

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
@@ -82,6 +82,18 @@ cannot loop with the request. A resize within about a second of the stream
82
82
  changing that matches its new shape counts as following it. Layout changes
83
83
  outside that window always report.
84
84
 
85
+ The feed frame (the element fullscreen targets) carries two attributes for
86
+ hosts and tests:
87
+
88
+ - `data-kc-signal`: `live`, `stalled` (no frame has left the decoder for
89
+ half a second, or none yet since the source attached) or `none` (no
90
+ source). It is measured after any encoded transform the host installs, so
91
+ a transform that holds frames back shows as `stalled` while it holds them.
92
+ The video keeps presenting frames while it shows static, so its frame
93
+ counts cannot tell a picture from static, and inbound-rtp `framesDecoded`
94
+ averaged over seconds misses gaps this short; this attribute can
95
+ - `data-fullscreen`: present while the feed is fullscreen
96
+
85
97
  An imperative `CameraFeedHandle` (via `ref`) exposes `stepCamera`,
86
98
  `setPanAxis`, `setZoomRate`, `nudgePan`, and `nudgeZoom` for wiring feeds
87
99
  to a gamepad or keyboard.
@@ -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;
@@ -161,10 +170,17 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
161
170
  }, []);
162
171
  // The feed frame; fullscreen targets this and ResizeObserver measures it.
163
172
  const wrapRef = useRef(null);
173
+ /* Clearing to null on a switch to a camera with no stream yet stops the
174
+ previous camera's last frame lingering. A rebind can hand over a new
175
+ MediaStream around the same track; reassigning that would reset the
176
+ element and flash black, so it is skipped. */
164
177
  useEffect(() => {
165
- if (videoRef.current && stream) {
166
- videoRef.current.srcObject = stream;
167
- }
178
+ const video = videoRef.current;
179
+ if (!video || video.srcObject === stream)
180
+ return;
181
+ if (sameVideoTrack(video.srcObject, stream))
182
+ return;
183
+ video.srcObject = stream;
168
184
  }, [stream]);
169
185
  // -------------------------------------------------------------------------
170
186
  // Stall presentation. The static is composited in-stream by the SDK's
@@ -186,6 +202,19 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
186
202
  setIsStale(cam.stalled);
187
203
  return cam.on("stall", setIsStale);
188
204
  }, [client, flightId]);
205
+ const [hasSource, setHasSource] = useState(false);
206
+ useEffect(() => {
207
+ if (flightId === null) {
208
+ setHasSource(false);
209
+ return;
210
+ }
211
+ const cam = client.camera(flightId);
212
+ setHasSource(cam.hasSource);
213
+ return cam.on("source", setHasSource);
214
+ }, [client, flightId]);
215
+ /* The in-stream static keeps the video element presenting frames, so a
216
+ frame count cannot tell a picture from static; this attribute can. */
217
+ const signal = flightId === null ? undefined : !hasSource ? "none" : isStale ? "stalled" : "live";
189
218
  // -------------------------------------------------------------------------
190
219
  // Fullscreen + Picture-in-Picture (opt-in, feature-detected)
191
220
  // -------------------------------------------------------------------------
@@ -224,6 +253,21 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
224
253
  v.removeEventListener("leavepictureinpicture", onLeave);
225
254
  };
226
255
  }, [pipAvailable, flightId]);
256
+ /* Browsers usually consume Escape to leave fullscreen themselves; this
257
+ covers hosts and embedded views that hand the key to the page instead. */
258
+ useEffect(() => {
259
+ if (!isFullscreen)
260
+ return;
261
+ const onKey = (e) => {
262
+ if (e.key === "Escape" &&
263
+ !e.defaultPrevented &&
264
+ currentFullscreenElement() === wrapRef.current) {
265
+ exitFullscreen();
266
+ }
267
+ };
268
+ document.addEventListener("keydown", onKey);
269
+ return () => document.removeEventListener("keydown", onKey);
270
+ }, [isFullscreen]);
227
271
  const toggleFullscreen = useCallback(() => {
228
272
  const el = wrapRef.current;
229
273
  if (!el)
@@ -583,7 +627,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
583
627
  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
628
  onSelectCamera?.(c.flightId);
585
629
  cameraMenu.close();
586
- }, children: [cameraLabel(c), " (", c.vesselName, ")", isCameraDestroyed(c) ? " - signal lost" : ""] }, c.flightId))) }), document.body), showDebugInfo &&
630
+ }, children: [cameraLabel(c), " (", c.vesselName, ")", isCameraDestroyed(c) ? " - signal lost" : ""] }, c.flightId))) }), menuPortalRoot()), showDebugInfo &&
587
631
  (camera ? (_jsxs(TopMeta, { children: [camera.vesselName, " \u00B7 ", camera.renderWidth, "\u00D7", camera.renderHeight, bitrateLabel, adaptiveLabel] })) : (_jsx(TopMeta, { children: "no cameras on this vessel" })))] }));
588
632
  /*
589
633
  * Built-in action-bar entries, in the bar's natural (fallback) order:
@@ -677,8 +721,11 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
677
721
  {
678
722
  id: "fullscreen",
679
723
  label: fullscreenLabel,
724
+ /* While fullscreen the exit stays inline, never in the ⋮ menu,
725
+ so it can be the one control the Stage keeps visible. */
680
726
  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, {}) })),
727
+ inline: isFullscreen,
728
+ 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
729
  },
683
730
  ]
684
731
  : []),
@@ -713,22 +760,22 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
713
760
  builtInActions);
714
761
  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
762
  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 &&
763
+ 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
764
  qualityMenu.open &&
718
765
  qualityMenu.position &&
719
766
  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 &&
767
+ 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
768
  trackingMenu.open &&
722
769
  trackingMenu.position &&
723
770
  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 &&
771
+ 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
772
  commandTargetMenu.open &&
726
773
  commandTargetMenu.position &&
727
774
  createPortal(_jsx(CameraMenu, { ref: commandTargetMenu.menuRef, id: commandTargetMenuId, role: "menu", "aria-label": "Target", style: commandTargetMenu.position, children: commandTargets.map((t) => {
728
775
  const eligible = t.eligibility === CommandTargetEligibility.Ok;
729
776
  const current = t.persistentId === commandTargetId;
730
777
  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 &&
778
+ }) }), menuPortalRoot()), commandState &&
732
779
  !outOfFlight &&
733
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) => {
734
781
  if (e.detail === 0)
@@ -1033,8 +1080,9 @@ const StepButtons = styled.div `
1033
1080
  flex-shrink: 0;
1034
1081
  `;
1035
1082
  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. */
1083
+ /* Portaled to document.body (the fullscreen element while fullscreen):
1084
+ fixed position (set inline from the trigger rect) keeps it clear of the
1085
+ tile's overflow clipping. */
1038
1086
  position: fixed;
1039
1087
  z-index: 1000;
1040
1088
  max-width: min(260px, calc(100vw - 16px));
@@ -1090,7 +1138,7 @@ const TopMeta = styled.div `
1090
1138
  color: rgba(255, 255, 255, 0.78);
1091
1139
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
1092
1140
  `;
1093
- /* The quality picker: portaled to document.body like CameraMenu, hung from
1141
+ /* The quality picker: portaled like CameraMenu, hung from
1094
1142
  its action-bar trigger (fixed position set inline from the trigger rect)
1095
1143
  so tile clipping cannot cut it off and it survives the hover-revealed
1096
1144
  chrome fading while the pointer is over the menu. */
@@ -1307,6 +1355,30 @@ const Stage = styled(Panel) `
1307
1355
  opacity: 1;
1308
1356
  }
1309
1357
  `}
1358
+
1359
+ /* Fullscreen has no hover on touch and may have no pointer at all, so the
1360
+ exit button stays visible in its usual slot while the rest of the bar
1361
+ keeps its hover/focus/tap reveal. */
1362
+ ${(p) => p.$fullscreen &&
1363
+ !p.$pinned &&
1364
+ css `
1365
+ ${ActionBarRow} {
1366
+ opacity: 1;
1367
+ pointer-events: auto;
1368
+ }
1369
+ ${ActionBarRow} > * {
1370
+ transition: opacity 0.15s;
1371
+ }
1372
+ &:not(:hover):not(:focus-within) ${ActionBarRow} > :not([data-kc-fullscreen-exit]) {
1373
+ opacity: 0;
1374
+ pointer-events: none;
1375
+ }
1376
+ @media (prefers-reduced-motion: reduce) {
1377
+ ${ActionBarRow} > * {
1378
+ transition: none;
1379
+ }
1380
+ }
1381
+ `}
1310
1382
  `;
1311
1383
  const Empty = styled.div `
1312
1384
  color: #888;
@@ -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
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Shared geometry + behaviour for a dropdown portaled to document.body.
3
- * Used by CameraFeed's camera / quality / tracking menus and by
2
+ * Shared geometry + behaviour for a portaled dropdown (document.body, or the
3
+ * fullscreen element while one is fullscreen). Used by CameraFeed's camera / quality / tracking menus and by
4
4
  * FeedActionBar's overflow menu, so the anchoring, dismissal, and Escape
5
5
  * handling live in exactly one place.
6
6
  */
@@ -10,6 +10,12 @@ export interface MenuAnchor {
10
10
  align: "start" | "end";
11
11
  }
12
12
  export declare function computeMenuPosition(trigger: HTMLElement, anchor: MenuAnchor): React.CSSProperties;
13
+ /**
14
+ * Where a dropdown portals to. Only the fullscreen element and its
15
+ * descendants render while something is fullscreen, so a menu portaled to
16
+ * document.body then would open invisibly behind it.
17
+ */
18
+ export declare function menuPortalRoot(): Element;
13
19
  export declare function usePortalMenu({ maxWidth, maxHeight, align }: MenuAnchor): {
14
20
  open: boolean;
15
21
  toggle: () => void;
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Shared geometry + behaviour for a dropdown portaled to document.body.
3
- * Used by CameraFeed's camera / quality / tracking menus and by
2
+ * Shared geometry + behaviour for a portaled dropdown (document.body, or the
3
+ * fullscreen element while one is fullscreen). Used by CameraFeed's camera / quality / tracking menus and by
4
4
  * FeedActionBar's overflow menu, so the anchoring, dismissal, and Escape
5
5
  * handling live in exactly one place.
6
6
  */
@@ -32,8 +32,18 @@ export function computeMenuPosition(trigger, anchor) {
32
32
  const top = Math.min(rect.bottom + MENU_GAP, Math.max(MENU_EDGE, vh - MENU_EDGE - menuH));
33
33
  return { ...inline, top };
34
34
  }
35
+ /**
36
+ * Where a dropdown portals to. Only the fullscreen element and its
37
+ * descendants render while something is fullscreen, so a menu portaled to
38
+ * document.body then would open invisibly behind it.
39
+ */
40
+ export function menuPortalRoot() {
41
+ const d = document;
42
+ return d.fullscreenElement ?? d.webkitFullscreenElement ?? document.body;
43
+ }
35
44
  /*
36
- * Shared behaviour for a dropdown portaled to document.body: fixed position
45
+ * Shared behaviour for a dropdown portaled to document.body (or the
46
+ * fullscreen element, see menuPortalRoot): fixed position
37
47
  * computed from the trigger's rect (so tile overflow cannot clip it),
38
48
  * re-anchored on window resize/scroll while open, Escape to close with focus
39
49
  * returned to the trigger, and portal-aware outside-pointer-down dismissal
@@ -68,24 +78,39 @@ export function usePortalMenu({ maxWidth, maxHeight, align }) {
68
78
  useEffect(() => {
69
79
  if (!open)
70
80
  return;
81
+ /* Capture phase and preventDefault so document-level Escape handlers
82
+ registered earlier (CameraFeed's exit-fullscreen key) see the press
83
+ as already consumed by the menu. */
71
84
  const onKeyDown = (e) => {
72
85
  if (e.key === "Escape") {
86
+ e.preventDefault();
73
87
  e.stopPropagation();
74
88
  setOpen(false);
75
89
  triggerRef.current?.focus();
76
90
  }
77
91
  };
92
+ /* The portal root changes with fullscreen and no resize may fire (kiosk,
93
+ F11), so a menu left open would remount elsewhere with stale
94
+ coordinates. */
95
+ const onFullscreenChange = () => {
96
+ setOpen(false);
97
+ triggerRef.current?.focus();
98
+ };
78
99
  const onPointerDown = (e) => {
79
100
  if (!menuRef.current?.contains(e.target) &&
80
101
  !triggerRef.current?.contains(e.target)) {
81
102
  setOpen(false);
82
103
  }
83
104
  };
84
- document.addEventListener("keydown", onKeyDown);
105
+ document.addEventListener("keydown", onKeyDown, true);
85
106
  document.addEventListener("pointerdown", onPointerDown);
107
+ document.addEventListener("fullscreenchange", onFullscreenChange);
108
+ document.addEventListener("webkitfullscreenchange", onFullscreenChange);
86
109
  return () => {
87
- document.removeEventListener("keydown", onKeyDown);
110
+ document.removeEventListener("keydown", onKeyDown, true);
88
111
  document.removeEventListener("pointerdown", onPointerDown);
112
+ document.removeEventListener("fullscreenchange", onFullscreenChange);
113
+ document.removeEventListener("webkitfullscreenchange", onFullscreenChange);
89
114
  };
90
115
  }, [open]);
91
116
  const toggle = useCallback(() => setOpen((v) => !v), []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksp-gonogo/kerbcast-react",
3
- "version": "1.12.0-rc.1",
3
+ "version": "1.12.0-rc.2",
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.1"
48
+ "@ksp-gonogo/kerbcast": "^1.12.0-rc.2"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": "^18 || ^19",
@@ -998,6 +998,107 @@ describe("CameraFeed - stall presentation (showStatic)", () => {
998
998
  }
999
999
  });
1000
1000
 
1001
+ it("data-kc-signal follows a feed into and out of a stall without remounting its video", async () => {
1002
+ const env = installStallEnv();
1003
+ try {
1004
+ const { client, sidecar } = await buildConnectedSource();
1005
+ const { container } = renderFeed(client, { flightId: 42 });
1006
+ const signal = () => container.querySelector("[data-kc-signal]")?.getAttribute("data-kc-signal");
1007
+ expect(signal()).toBe("none");
1008
+
1009
+ await act(async () => {
1010
+ sidecar.deliverTrack(sidecar.slotMidFor(42) as string, {} as MediaStreamTrack);
1011
+ });
1012
+ env.setNow(1000);
1013
+ await act(async () => {
1014
+ env.fireFrame();
1015
+ env.drawOnce();
1016
+ });
1017
+ expect(signal()).toBe("live");
1018
+ const video = container.querySelector("video");
1019
+
1020
+ env.setNow(1601);
1021
+ await act(async () => {
1022
+ env.drawOnce();
1023
+ });
1024
+ expect(signal()).toBe("stalled");
1025
+ expect(container.querySelector("video")).toBe(video);
1026
+
1027
+ await act(async () => {
1028
+ env.fireFrame();
1029
+ env.drawOnce();
1030
+ });
1031
+ expect(signal()).toBe("live");
1032
+ expect(container.querySelector("video")).toBe(video);
1033
+ } finally {
1034
+ env.restore();
1035
+ }
1036
+ });
1037
+
1038
+ it("data-kc-signal reads stalled for a source that never delivers a frame", async () => {
1039
+ const env = installStallEnv();
1040
+ try {
1041
+ const { client, sidecar } = await buildConnectedSource();
1042
+ const { container } = renderFeed(client, { flightId: 42 });
1043
+ const signal = () => container.querySelector("[data-kc-signal]")?.getAttribute("data-kc-signal");
1044
+
1045
+ env.setNow(1000);
1046
+ await act(async () => {
1047
+ sidecar.deliverTrack(sidecar.slotMidFor(42) as string, {} as MediaStreamTrack);
1048
+ env.drawOnce();
1049
+ });
1050
+ env.setNow(1601);
1051
+ await act(async () => {
1052
+ env.drawOnce();
1053
+ });
1054
+ expect(signal()).toBe("stalled");
1055
+ } finally {
1056
+ env.restore();
1057
+ }
1058
+ });
1059
+
1060
+ it("data-kc-signal reads stalled after a camera switch until the new camera's first frame", async () => {
1061
+ const env = installStallEnv();
1062
+ try {
1063
+ const { client, sidecar } = await buildConnectedSource([
1064
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
1065
+ makeCamera({ flightId: 43, cameraName: "Port Cam" }),
1066
+ ]);
1067
+ const view = renderFeed(client, { flightId: 42 });
1068
+ const signal = () => view.container.querySelector("[data-kc-signal]")?.getAttribute("data-kc-signal");
1069
+ await act(async () => {
1070
+ sidecar.deliverTrack(sidecar.slotMidFor(42) as string, {} as MediaStreamTrack);
1071
+ });
1072
+ env.setNow(1000);
1073
+ await act(async () => {
1074
+ env.fireFrame();
1075
+ env.drawOnce();
1076
+ });
1077
+ expect(signal()).toBe("live");
1078
+
1079
+ /* The freed slot passes straight to the new camera, so there is a
1080
+ source at once but no frame of it yet. */
1081
+ view.rerender(
1082
+ <KerbcastProvider client={client}>
1083
+ <CameraFeed flightId={43} />
1084
+ </KerbcastProvider>,
1085
+ );
1086
+ env.setNow(1601);
1087
+ await act(async () => {
1088
+ env.drawOnce();
1089
+ });
1090
+ expect(signal()).toBe("stalled");
1091
+
1092
+ await act(async () => {
1093
+ env.fireFrame();
1094
+ env.drawOnce();
1095
+ });
1096
+ expect(signal()).toBe("live");
1097
+ } finally {
1098
+ env.restore();
1099
+ }
1100
+ });
1101
+
1001
1102
  it("SIGNAL LOST supersedes the stale badge when the camera is destroyed", async () => {
1002
1103
  const env = installStallEnv();
1003
1104
  try {
@@ -3230,3 +3331,366 @@ describe("CameraFeed - anchors", () => {
3230
3331
  warn.mockRestore();
3231
3332
  });
3232
3333
  });
3334
+
3335
+ describe("CameraFeed - fullscreen exit without a mouse", () => {
3336
+ type FsDoc = Document & {
3337
+ fullscreenEnabled?: boolean;
3338
+ fullscreenElement?: Element | null;
3339
+ exitFullscreen?: () => Promise<void>;
3340
+ };
3341
+ const fsDoc = document as FsDoc;
3342
+ let fsElement: Element | null = null;
3343
+ const exitSpy = vi.fn(async () => {
3344
+ fsElement = null;
3345
+ document.dispatchEvent(new Event("fullscreenchange"));
3346
+ });
3347
+
3348
+ beforeEach(() => {
3349
+ fsElement = null;
3350
+ exitSpy.mockClear();
3351
+ Object.defineProperty(fsDoc, "fullscreenEnabled", { value: true, configurable: true });
3352
+ Object.defineProperty(fsDoc, "fullscreenElement", {
3353
+ get: () => fsElement,
3354
+ configurable: true,
3355
+ });
3356
+ Object.defineProperty(fsDoc, "exitFullscreen", { value: exitSpy, configurable: true });
3357
+ Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
3358
+ value: async function (this: HTMLElement) {
3359
+ fsElement = this;
3360
+ document.dispatchEvent(new Event("fullscreenchange"));
3361
+ },
3362
+ configurable: true,
3363
+ });
3364
+ });
3365
+
3366
+ afterEach(() => {
3367
+ delete (fsDoc as { fullscreenEnabled?: boolean }).fullscreenEnabled;
3368
+ delete (fsDoc as { fullscreenElement?: Element | null }).fullscreenElement;
3369
+ delete (fsDoc as { exitFullscreen?: unknown }).exitFullscreen;
3370
+ delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
3371
+ delete (document as { pictureInPictureEnabled?: boolean }).pictureInPictureEnabled;
3372
+ });
3373
+
3374
+ async function enterFullscreen() {
3375
+ const pipDoc = document as Document & { pictureInPictureEnabled?: boolean };
3376
+ Object.defineProperty(pipDoc, "pictureInPictureEnabled", { value: true, configurable: true });
3377
+ const { client } = await buildConnectedSource([
3378
+ makeCamera({
3379
+ flightId: 42,
3380
+ cameraName: "Launchpad Cam",
3381
+ supportsPan: true,
3382
+ supportsZoom: true,
3383
+ panPitchMin: -90,
3384
+ panPitchMax: 90,
3385
+ }),
3386
+ ]);
3387
+ /* Crowded enough (REC + tracking + PiP + fullscreen) that fullscreen
3388
+ starts out in the ⋮ overflow. */
3389
+ const utils = renderFeed(client, {
3390
+ flightId: 42,
3391
+ enableRecording: true,
3392
+ enableTracking: true,
3393
+ enableFullscreen: true,
3394
+ enablePictureInPicture: true,
3395
+ });
3396
+ fireEvent.click(utils.getByLabelText("More actions"));
3397
+ await act(async () => {
3398
+ fireEvent.click(utils.getByLabelText("Enter fullscreen"));
3399
+ });
3400
+ return utils;
3401
+ }
3402
+
3403
+ it("keeps the exit inline and marked as the always-visible control", async () => {
3404
+ const { getByLabelText } = await enterFullscreen();
3405
+ const exit = getByLabelText("Exit fullscreen");
3406
+ expect(exit.hasAttribute("data-kc-fullscreen-exit")).toBe(true);
3407
+ expect(exit.closest("[role=menu]")).toBeNull();
3408
+ expect(exit.closest("[data-fullscreen]")).toBe(fsElement);
3409
+ expect(exit.tabIndex).toBe(0);
3410
+ /* The ⋮ menu hands focus back to its trigger, which reveals the whole
3411
+ bar by focus-within; at rest only the exit shows. */
3412
+ (document.activeElement as HTMLElement | null)?.blur();
3413
+ expect(getComputedStyle(exit).opacity).not.toBe("0");
3414
+ expect(getComputedStyle(exit).pointerEvents).not.toBe("none");
3415
+ expect(getComputedStyle(getByLabelText("Auto-track")).opacity).toBe("0");
3416
+
3417
+ await act(async () => {
3418
+ fireEvent.click(exit);
3419
+ });
3420
+ expect(exitSpy).toHaveBeenCalledTimes(1);
3421
+ expect(document.querySelector("[data-kc-fullscreen-exit]")).toBeNull();
3422
+ expect(document.querySelector("[data-fullscreen]")).toBeNull();
3423
+ });
3424
+
3425
+ it("entering fullscreen adds the exit without moving the rest of the bar", async () => {
3426
+ const barLabels = () => {
3427
+ const row = screen.getByLabelText("More actions").parentElement as HTMLElement;
3428
+ return Array.from(row.children).map((c) => c.getAttribute("aria-label") ?? "steps");
3429
+ };
3430
+ const pipDoc = document as Document & { pictureInPictureEnabled?: boolean };
3431
+ Object.defineProperty(pipDoc, "pictureInPictureEnabled", { value: true, configurable: true });
3432
+ const { client } = await buildConnectedSource([
3433
+ makeCamera({
3434
+ flightId: 42,
3435
+ cameraName: "Launchpad Cam",
3436
+ supportsPan: true,
3437
+ supportsZoom: true,
3438
+ panPitchMin: -90,
3439
+ panPitchMax: 90,
3440
+ }),
3441
+ ]);
3442
+ const { getByLabelText } = renderFeed(client, {
3443
+ flightId: 42,
3444
+ enableRecording: true,
3445
+ enableTracking: true,
3446
+ enableFullscreen: true,
3447
+ enablePictureInPicture: true,
3448
+ trailingActions: [{ id: "x", label: "Close", icon: null, onClick: () => {} }],
3449
+ });
3450
+ const before = barLabels();
3451
+ expect(before).toContain("More actions");
3452
+ expect(before).not.toContain("Picture in picture");
3453
+
3454
+ fireEvent.click(getByLabelText("More actions"));
3455
+ await act(async () => {
3456
+ fireEvent.click(getByLabelText("Enter fullscreen"));
3457
+ });
3458
+ const after = barLabels();
3459
+ expect(after.filter((l) => l !== "Exit fullscreen")).toEqual(before);
3460
+ expect(after.indexOf("Exit fullscreen")).toBe(after.indexOf("More actions") - 1);
3461
+ });
3462
+
3463
+ it("leaves an Escape another handler already consumed alone", async () => {
3464
+ await enterFullscreen();
3465
+ const consumed = new KeyboardEvent("keydown", { key: "Escape", cancelable: true });
3466
+ consumed.preventDefault();
3467
+ await act(async () => {
3468
+ document.dispatchEvent(consumed);
3469
+ });
3470
+ expect(exitSpy).not.toHaveBeenCalled();
3471
+ });
3472
+
3473
+ it("a tap on the video reveals the rest of the bar", async () => {
3474
+ const { getByLabelText } = await enterFullscreen();
3475
+ (document.activeElement as HTMLElement | null)?.blur();
3476
+ expect(getComputedStyle(getByLabelText("Auto-track")).opacity).toBe("0");
3477
+ const video = document.querySelector("video") as HTMLVideoElement;
3478
+ fireEvent.click(video);
3479
+ expect(getComputedStyle(getByLabelText("Auto-track")).opacity).not.toBe("0");
3480
+ });
3481
+
3482
+ it("Escape leaves fullscreen", async () => {
3483
+ const { queryByLabelText } = await enterFullscreen();
3484
+ await act(async () => {
3485
+ fireEvent.keyDown(document, { key: "Escape" });
3486
+ });
3487
+ expect(exitSpy).toHaveBeenCalledTimes(1);
3488
+ expect(queryByLabelText("Exit fullscreen")).toBeNull();
3489
+ });
3490
+ });
3491
+
3492
+ describe("CameraFeed - video source on camera switch", () => {
3493
+ const TWO_CAMERAS = [
3494
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
3495
+ makeCamera({ flightId: 43, cameraName: "Nose Cam" }),
3496
+ ];
3497
+
3498
+ it("clears the previous frame when the new camera has no stream yet", async () => {
3499
+ const { client } = await buildConnectedSource(TWO_CAMERAS);
3500
+ const sentinel = {} as MediaStream;
3501
+ const useCustom: CameraStreamHook = (id) => (id === 42 ? sentinel : null);
3502
+ const view = (flightId: number) => (
3503
+ <KerbcastProvider client={client}>
3504
+ <CameraFeed flightId={flightId} useStream={useCustom} />
3505
+ </KerbcastProvider>
3506
+ );
3507
+ const { rerender } = render(view(42));
3508
+ const video = document.querySelector("video") as HTMLVideoElement;
3509
+ expect(video.srcObject).toBe(sentinel);
3510
+
3511
+ rerender(view(43));
3512
+ expect(document.querySelector("video")).toBe(video);
3513
+ expect(video.srcObject).toBeNull();
3514
+ });
3515
+
3516
+ it("keeps the element bound when a rebind wraps the same track in a new stream", async () => {
3517
+ const { client } = await buildConnectedSource(TWO_CAMERAS);
3518
+ const track = { id: "track-1", kind: "video" } as MediaStreamTrack;
3519
+ const wrap = () =>
3520
+ ({ getVideoTracks: () => [track] }) as unknown as MediaStream;
3521
+ const first = wrap();
3522
+ const second = wrap();
3523
+ const view = (stream: MediaStream) => {
3524
+ const useFixed: CameraStreamHook = () => stream;
3525
+ return (
3526
+ <KerbcastProvider client={client}>
3527
+ <CameraFeed flightId={42} useStream={useFixed} />
3528
+ </KerbcastProvider>
3529
+ );
3530
+ };
3531
+ const { rerender } = render(view(first));
3532
+ const video = document.querySelector("video") as HTMLVideoElement;
3533
+ expect(video.srcObject).toBe(first);
3534
+
3535
+ rerender(view(second));
3536
+ expect(video.srcObject).toBe(first);
3537
+
3538
+ const other = { getVideoTracks: () => [{ id: "track-2" }] } as unknown as MediaStream;
3539
+ rerender(view(other));
3540
+ expect(video.srcObject).toBe(other);
3541
+ });
3542
+
3543
+ it("never reassigns a live stream on a re-render", async () => {
3544
+ const { client } = await buildConnectedSource(TWO_CAMERAS);
3545
+ const sentinel = {} as MediaStream;
3546
+ const useCustom: CameraStreamHook = () => sentinel;
3547
+ const view = (debug: boolean) => (
3548
+ <KerbcastProvider client={client}>
3549
+ <CameraFeed flightId={42} useStream={useCustom} showDebugInfo={debug} />
3550
+ </KerbcastProvider>
3551
+ );
3552
+ const { rerender } = render(view(false));
3553
+ const video = document.querySelector("video") as HTMLVideoElement;
3554
+ expect(video.srcObject).toBe(sentinel);
3555
+
3556
+ let current: MediaProvider | null = sentinel;
3557
+ const sets: (MediaProvider | null)[] = [];
3558
+ Object.defineProperty(video, "srcObject", {
3559
+ get: () => current,
3560
+ set: (v: MediaProvider | null) => {
3561
+ sets.push(v);
3562
+ current = v;
3563
+ },
3564
+ configurable: true,
3565
+ });
3566
+ rerender(view(true));
3567
+ rerender(view(false));
3568
+ expect(sets).toEqual([]);
3569
+ expect(video.srcObject).toBe(sentinel);
3570
+ });
3571
+ });
3572
+
3573
+ describe("CameraFeed - menus in fullscreen", () => {
3574
+ type FsDoc = Document & {
3575
+ fullscreenEnabled?: boolean;
3576
+ fullscreenElement?: Element | null;
3577
+ pictureInPictureEnabled?: boolean;
3578
+ };
3579
+ const fsDoc = document as FsDoc;
3580
+ let fsElement: Element | null = null;
3581
+
3582
+ beforeEach(() => {
3583
+ fsElement = null;
3584
+ Object.defineProperty(fsDoc, "fullscreenEnabled", { value: true, configurable: true });
3585
+ Object.defineProperty(fsDoc, "pictureInPictureEnabled", { value: true, configurable: true });
3586
+ Object.defineProperty(fsDoc, "fullscreenElement", {
3587
+ get: () => fsElement,
3588
+ configurable: true,
3589
+ });
3590
+ Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
3591
+ value: async function (this: HTMLElement) {
3592
+ fsElement = this;
3593
+ document.dispatchEvent(new Event("fullscreenchange"));
3594
+ },
3595
+ configurable: true,
3596
+ });
3597
+ });
3598
+
3599
+ afterEach(() => {
3600
+ delete (fsDoc as { fullscreenEnabled?: boolean }).fullscreenEnabled;
3601
+ delete (fsDoc as { pictureInPictureEnabled?: boolean }).pictureInPictureEnabled;
3602
+ delete (fsDoc as { fullscreenElement?: Element | null }).fullscreenElement;
3603
+ delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
3604
+ });
3605
+
3606
+ it("opens the more actions menu inside the fullscreen feed on the first tap", async () => {
3607
+ const { client } = await buildConnectedSource();
3608
+ const { getByLabelText, getByRole } = renderFeed(client, {
3609
+ flightId: 42,
3610
+ enableFullscreen: true,
3611
+ enablePictureInPicture: true,
3612
+ actions: [
3613
+ { id: "a", label: "Spotlight", icon: null, onClick: () => {} },
3614
+ { id: "b", label: "Snapshot", icon: null, onClick: () => {} },
3615
+ ],
3616
+ trailingActions: [{ id: "x", label: "Close", icon: null, onClick: () => {} }],
3617
+ });
3618
+ /* Crowded enough that ⋮ stays even once fullscreen pins its exit inline. */
3619
+ fireEvent.click(getByLabelText("More actions"));
3620
+ await act(async () => {
3621
+ fireEvent.click(getByLabelText("Enter fullscreen"));
3622
+ });
3623
+ expect(fsElement).not.toBeNull();
3624
+
3625
+ const trigger = getByLabelText("More actions");
3626
+ fireEvent.pointerDown(trigger);
3627
+ fireEvent.click(trigger);
3628
+ const menu = getByRole("menu", { name: "More actions" });
3629
+ expect(fsElement?.contains(menu)).toBe(true);
3630
+
3631
+ fireEvent.pointerDown(menu);
3632
+ expect(getByRole("menu", { name: "More actions" })).toBe(menu);
3633
+ });
3634
+
3635
+ async function openMenuInFullscreen() {
3636
+ const { client } = await buildConnectedSource();
3637
+ const utils = renderFeed(client, {
3638
+ flightId: 42,
3639
+ enableFullscreen: true,
3640
+ enablePictureInPicture: true,
3641
+ actions: [
3642
+ { id: "a", label: "Spotlight", icon: null, onClick: () => {} },
3643
+ { id: "b", label: "Snapshot", icon: null, onClick: () => {} },
3644
+ ],
3645
+ });
3646
+ fireEvent.click(utils.getByLabelText("More actions"));
3647
+ await act(async () => {
3648
+ fireEvent.click(utils.getByLabelText("Enter fullscreen"));
3649
+ });
3650
+ fireEvent.click(utils.getByLabelText("More actions"));
3651
+ expect(utils.getByRole("menu", { name: "More actions" })).toBeTruthy();
3652
+ return utils;
3653
+ }
3654
+
3655
+ it("Escape with a menu open closes the menu and stays fullscreen", async () => {
3656
+ const exitSpy = vi.fn();
3657
+ Object.defineProperty(fsDoc, "exitFullscreen", { value: exitSpy, configurable: true });
3658
+ try {
3659
+ const { queryByRole, getByLabelText, getByRole } = await openMenuInFullscreen();
3660
+ await act(async () => {
3661
+ fireEvent.keyDown(getByRole("button", { name: "More actions" }), { key: "Escape" });
3662
+ });
3663
+ expect(queryByRole("menu", { name: "More actions" })).toBeNull();
3664
+ expect(exitSpy).not.toHaveBeenCalled();
3665
+ expect(document.activeElement).toBe(getByLabelText("More actions"));
3666
+ } finally {
3667
+ delete (fsDoc as { exitFullscreen?: unknown }).exitFullscreen;
3668
+ }
3669
+ });
3670
+
3671
+ it("closes an open menu when fullscreen ends", async () => {
3672
+ const { queryByRole, getByLabelText } = await openMenuInFullscreen();
3673
+ await act(async () => {
3674
+ fsElement = null;
3675
+ document.dispatchEvent(new Event("fullscreenchange"));
3676
+ });
3677
+ expect(queryByRole("menu", { name: "More actions" })).toBeNull();
3678
+ expect(document.activeElement).toBe(getByLabelText("More actions"));
3679
+ });
3680
+
3681
+ it("still portals menus to the body when not fullscreen", async () => {
3682
+ const { client } = await buildConnectedSource();
3683
+ const { getByLabelText, getByRole } = renderFeed(client, {
3684
+ flightId: 42,
3685
+ enableFullscreen: true,
3686
+ enablePictureInPicture: true,
3687
+ actions: [
3688
+ { id: "a", label: "Spotlight", icon: null, onClick: () => {} },
3689
+ { id: "b", label: "Snapshot", icon: null, onClick: () => {} },
3690
+ ],
3691
+ });
3692
+ fireEvent.click(getByLabelText("More actions"));
3693
+ const menu = getByRole("menu", { name: "More actions" });
3694
+ expect(menu.parentElement).toBe(document.body);
3695
+ });
3696
+ });
@@ -45,7 +45,7 @@ import { useTargetBracketsPreference } from "./hooks/useTargetBracketsPreference
45
45
  import { StandbyIcon } from "./StandbyIcon";
46
46
  import { isCameraDestroyed } from "./lifecycle";
47
47
  import { claimAnchors } from "./sharedAnchors";
48
- import { usePortalMenu } from "./menuPositioning";
48
+ import { menuPortalRoot, usePortalMenu } from "./menuPositioning";
49
49
  import { formatElapsed, nowMs } from "./timing";
50
50
 
51
51
  // ---------------------------------------------------------------------------
@@ -78,6 +78,16 @@ function presetDim(operatorDim: number, scale: number): number {
78
78
  return v < 2 ? 2 : v;
79
79
  }
80
80
 
81
+ function videoTrackId(source: MediaProvider | null): string | undefined {
82
+ if (!source || !("getVideoTracks" in source)) return undefined;
83
+ return source.getVideoTracks()[0]?.id;
84
+ }
85
+
86
+ function sameVideoTrack(a: MediaProvider | null, b: MediaStream | null): boolean {
87
+ const id = videoTrackId(a);
88
+ return id !== undefined && id === videoTrackId(b);
89
+ }
90
+
81
91
  /*
82
92
  * Fullscreen helpers. Safari (incl. iPadOS) only exposes the webkit-prefixed
83
93
  * API; iOS iPhone has no element fullscreen at all, which `isFullscreenSupported`
@@ -520,10 +530,15 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
520
530
  }, []);
521
531
  // The feed frame; fullscreen targets this and ResizeObserver measures it.
522
532
  const wrapRef = useRef<HTMLDivElement>(null);
533
+ /* Clearing to null on a switch to a camera with no stream yet stops the
534
+ previous camera's last frame lingering. A rebind can hand over a new
535
+ MediaStream around the same track; reassigning that would reset the
536
+ element and flash black, so it is skipped. */
523
537
  useEffect(() => {
524
- if (videoRef.current && stream) {
525
- videoRef.current.srcObject = stream;
526
- }
538
+ const video = videoRef.current;
539
+ if (!video || video.srcObject === stream) return;
540
+ if (sameVideoTrack(video.srcObject, stream)) return;
541
+ video.srcObject = stream;
527
542
  }, [stream]);
528
543
 
529
544
  // -------------------------------------------------------------------------
@@ -547,6 +562,20 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
547
562
  return cam.on("stall", setIsStale);
548
563
  }, [client, flightId]);
549
564
 
565
+ const [hasSource, setHasSource] = useState(false);
566
+ useEffect(() => {
567
+ if (flightId === null) {
568
+ setHasSource(false);
569
+ return;
570
+ }
571
+ const cam = client.camera(flightId);
572
+ setHasSource(cam.hasSource);
573
+ return cam.on("source", setHasSource);
574
+ }, [client, flightId]);
575
+ /* The in-stream static keeps the video element presenting frames, so a
576
+ frame count cannot tell a picture from static; this attribute can. */
577
+ const signal = flightId === null ? undefined : !hasSource ? "none" : isStale ? "stalled" : "live";
578
+
550
579
  // -------------------------------------------------------------------------
551
580
  // Fullscreen + Picture-in-Picture (opt-in, feature-detected)
552
581
  // -------------------------------------------------------------------------
@@ -588,6 +617,23 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
588
617
  };
589
618
  }, [pipAvailable, flightId]);
590
619
 
620
+ /* Browsers usually consume Escape to leave fullscreen themselves; this
621
+ covers hosts and embedded views that hand the key to the page instead. */
622
+ useEffect(() => {
623
+ if (!isFullscreen) return;
624
+ const onKey = (e: KeyboardEvent) => {
625
+ if (
626
+ e.key === "Escape" &&
627
+ !e.defaultPrevented &&
628
+ currentFullscreenElement() === wrapRef.current
629
+ ) {
630
+ exitFullscreen();
631
+ }
632
+ };
633
+ document.addEventListener("keydown", onKey);
634
+ return () => document.removeEventListener("keydown", onKey);
635
+ }, [isFullscreen]);
636
+
591
637
  const toggleFullscreen = useCallback(() => {
592
638
  const el = wrapRef.current;
593
639
  if (!el) return;
@@ -1053,7 +1099,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1053
1099
  </CameraMenuItem>
1054
1100
  ))}
1055
1101
  </CameraMenu>,
1056
- document.body,
1102
+ menuPortalRoot(),
1057
1103
  )}
1058
1104
 
1059
1105
  {showDebugInfo &&
@@ -1243,10 +1289,14 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1243
1289
  {
1244
1290
  id: "fullscreen",
1245
1291
  label: fullscreenLabel,
1292
+ /* While fullscreen the exit stays inline, never in the ⋮ menu,
1293
+ so it can be the one control the Stage keeps visible. */
1246
1294
  stateful: false,
1295
+ inline: isFullscreen,
1247
1296
  render: () => (
1248
1297
  <OverlayIconButton
1249
1298
  type="button"
1299
+ data-kc-fullscreen-exit={isFullscreen || undefined}
1250
1300
  aria-label={fullscreenLabel}
1251
1301
  aria-pressed={isFullscreen}
1252
1302
  title={fullscreenLabel}
@@ -1327,7 +1377,13 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1327
1377
 
1328
1378
  return (
1329
1379
  <FeedBox style={feedShape}>
1330
- <Stage ref={wrapRef} $pinned={chromePinned}>
1380
+ <Stage
1381
+ ref={wrapRef}
1382
+ $pinned={chromePinned}
1383
+ $fullscreen={isFullscreen}
1384
+ data-fullscreen={isFullscreen || undefined}
1385
+ data-kc-signal={signal}
1386
+ >
1331
1387
  {flightId === null ? (
1332
1388
  <>
1333
1389
  <Empty>{emptyMessage}</Empty>
@@ -1385,7 +1441,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1385
1441
  {qualityThrottled ? " · throttled" : ""}
1386
1442
  </QualityMeta>
1387
1443
  </QualityMenu>,
1388
- document.body,
1444
+ menuPortalRoot(),
1389
1445
  )}
1390
1446
  {trackingAvailable &&
1391
1447
  trackingMenu.open &&
@@ -1418,7 +1474,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1418
1474
  Track target
1419
1475
  </CameraMenuItem>
1420
1476
  </QualityMenu>,
1421
- document.body,
1477
+ menuPortalRoot(),
1422
1478
  )}
1423
1479
  {commandTargetAvailable &&
1424
1480
  commandTargetMenu.open &&
@@ -1452,7 +1508,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1452
1508
  );
1453
1509
  })}
1454
1510
  </CameraMenu>,
1455
- document.body,
1511
+ menuPortalRoot(),
1456
1512
  )}
1457
1513
  {commandState &&
1458
1514
  !outOfFlight &&
@@ -2027,8 +2083,9 @@ const StepButtons = styled.div`
2027
2083
  `;
2028
2084
 
2029
2085
  const CameraMenu = styled.div`
2030
- /* Portaled to document.body: fixed position (set inline from the trigger
2031
- rect) keeps it clear of the tile's overflow clipping. */
2086
+ /* Portaled to document.body (the fullscreen element while fullscreen):
2087
+ fixed position (set inline from the trigger rect) keeps it clear of the
2088
+ tile's overflow clipping. */
2032
2089
  position: fixed;
2033
2090
  z-index: 1000;
2034
2091
  max-width: min(260px, calc(100vw - 16px));
@@ -2088,7 +2145,7 @@ const TopMeta = styled.div`
2088
2145
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
2089
2146
  `;
2090
2147
 
2091
- /* The quality picker: portaled to document.body like CameraMenu, hung from
2148
+ /* The quality picker: portaled like CameraMenu, hung from
2092
2149
  its action-bar trigger (fixed position set inline from the trigger rect)
2093
2150
  so tile clipping cannot cut it off and it survives the hover-revealed
2094
2151
  chrome fading while the pointer is over the menu. */
@@ -2274,7 +2331,7 @@ const FeedBox = styled.div`
2274
2331
  justify-content: center;
2275
2332
  `;
2276
2333
 
2277
- const Stage = styled(Panel)<{ $pinned: boolean }>`
2334
+ const Stage = styled(Panel)<{ $pinned: boolean; $fullscreen: boolean }>`
2278
2335
  width: min(100cqw, 100cqh * var(--kc-feed-w) / var(--kc-feed-h));
2279
2336
  height: auto;
2280
2337
  aspect-ratio: var(--kc-feed-w) / var(--kc-feed-h);
@@ -2319,6 +2376,31 @@ const Stage = styled(Panel)<{ $pinned: boolean }>`
2319
2376
  opacity: 1;
2320
2377
  }
2321
2378
  `}
2379
+
2380
+ /* Fullscreen has no hover on touch and may have no pointer at all, so the
2381
+ exit button stays visible in its usual slot while the rest of the bar
2382
+ keeps its hover/focus/tap reveal. */
2383
+ ${(p) =>
2384
+ p.$fullscreen &&
2385
+ !p.$pinned &&
2386
+ css`
2387
+ ${ActionBarRow} {
2388
+ opacity: 1;
2389
+ pointer-events: auto;
2390
+ }
2391
+ ${ActionBarRow} > * {
2392
+ transition: opacity 0.15s;
2393
+ }
2394
+ &:not(:hover):not(:focus-within) ${ActionBarRow} > :not([data-kc-fullscreen-exit]) {
2395
+ opacity: 0;
2396
+ pointer-events: none;
2397
+ }
2398
+ @media (prefers-reduced-motion: reduce) {
2399
+ ${ActionBarRow} > * {
2400
+ transition: none;
2401
+ }
2402
+ }
2403
+ `}
2322
2404
  `;
2323
2405
 
2324
2406
  const Empty = styled.div`
@@ -13,6 +13,7 @@ import { partitionActionBarEntries } from "./FeedActionBar";
13
13
  interface Entry {
14
14
  id: string;
15
15
  stateful?: boolean;
16
+ inline?: boolean;
16
17
  order?: number;
17
18
  pinnedTrailing?: boolean;
18
19
  }
@@ -41,6 +42,27 @@ describe("partitionActionBarEntries", () => {
41
42
  expect(ids(overflow)).toEqual(["quality", "pip", "fullscreen"]);
42
43
  });
43
44
 
45
+ it("keeps an inline entry out of overflow without changing the split", () => {
46
+ const base: Entry[] = [
47
+ { id: "tracking", stateful: true },
48
+ { id: "quality" },
49
+ { id: "pip" },
50
+ { id: "fullscreen" },
51
+ ];
52
+ const pinned = base.map((e) => (e.id === "fullscreen" ? { ...e, inline: true } : e));
53
+ const { primary, overflow } = partitionActionBarEntries(pinned);
54
+ expect(ids(primary)).toEqual(["tracking", "fullscreen"]);
55
+ expect(ids(overflow)).toEqual(["quality", "pip"]);
56
+
57
+ const twoNonStateful: Entry[] = [
58
+ { id: "tracking", stateful: true },
59
+ { id: "rec", stateful: true },
60
+ { id: "pip" },
61
+ { id: "fullscreen", inline: true },
62
+ ];
63
+ expect(ids(partitionActionBarEntries(twoNonStateful).overflow)).toEqual(["pip"]);
64
+ });
65
+
44
66
  it("never collapses a single non-stateful item, even with total >= 4", () => {
45
67
  const entries: Entry[] = [
46
68
  { id: "tracking", stateful: true },
@@ -29,7 +29,7 @@
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
  /**
35
35
  * A consumer-supplied action rendered into a feed's action bar. Lets a host
@@ -82,6 +82,7 @@ const OVERFLOW_MIN_NON_STATEFUL = 2;
82
82
 
83
83
  interface Partitionable {
84
84
  stateful?: boolean;
85
+ inline?: boolean;
85
86
  order?: number;
86
87
  pinnedTrailing?: boolean;
87
88
  }
@@ -113,8 +114,8 @@ export function partitionActionBarEntries<T extends Partitionable>(
113
114
  return { primary: overflowable, overflow: [], pinnedTrailing };
114
115
  }
115
116
  return {
116
- primary: overflowable.filter((e) => e.stateful),
117
- overflow: nonStateful,
117
+ primary: overflowable.filter((e) => e.stateful || e.inline),
118
+ overflow: nonStateful.filter((e) => !e.inline),
118
119
  pinnedTrailing,
119
120
  };
120
121
  }
@@ -133,6 +134,12 @@ export interface FeedActionBarEntry {
133
134
  id: string;
134
135
  label: string;
135
136
  stateful?: boolean;
137
+ /**
138
+ * Keeps a non-stateful entry out of the ⋮ menu without changing the split:
139
+ * it still counts as non-stateful toward the overflow threshold, so the
140
+ * rest of the bar lays out exactly as it would without the flag.
141
+ */
142
+ inline?: boolean;
136
143
  order?: number;
137
144
  pinnedTrailing?: boolean;
138
145
  render: () => React.ReactNode;
@@ -244,7 +251,7 @@ export function FeedActionBar({ leading, entries, className }: FeedActionBarProp
244
251
  </OverflowMenuItem>
245
252
  ))}
246
253
  </OverflowMenu>,
247
- document.body,
254
+ menuPortalRoot(),
248
255
  )}
249
256
  {pinnedTrailing.map((e) => (
250
257
  <Fragment key={e.id}>{e.render()}</Fragment>
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Shared geometry + behaviour for a dropdown portaled to document.body.
3
- * Used by CameraFeed's camera / quality / tracking menus and by
2
+ * Shared geometry + behaviour for a portaled dropdown (document.body, or the
3
+ * fullscreen element while one is fullscreen). Used by CameraFeed's camera / quality / tracking menus and by
4
4
  * FeedActionBar's overflow menu, so the anchoring, dismissal, and Escape
5
5
  * handling live in exactly one place.
6
6
  */
@@ -55,8 +55,19 @@ export function computeMenuPosition(
55
55
  return { ...inline, top };
56
56
  }
57
57
 
58
+ /**
59
+ * Where a dropdown portals to. Only the fullscreen element and its
60
+ * descendants render while something is fullscreen, so a menu portaled to
61
+ * document.body then would open invisibly behind it.
62
+ */
63
+ export function menuPortalRoot(): Element {
64
+ const d = document as Document & { webkitFullscreenElement?: Element | null };
65
+ return d.fullscreenElement ?? d.webkitFullscreenElement ?? document.body;
66
+ }
67
+
58
68
  /*
59
- * Shared behaviour for a dropdown portaled to document.body: fixed position
69
+ * Shared behaviour for a dropdown portaled to document.body (or the
70
+ * fullscreen element, see menuPortalRoot): fixed position
60
71
  * computed from the trigger's rect (so tile overflow cannot clip it),
61
72
  * re-anchored on window resize/scroll while open, Escape to close with focus
62
73
  * returned to the trigger, and portal-aware outside-pointer-down dismissal
@@ -92,13 +103,24 @@ export function usePortalMenu({ maxWidth, maxHeight, align }: MenuAnchor) {
92
103
 
93
104
  useEffect(() => {
94
105
  if (!open) return;
106
+ /* Capture phase and preventDefault so document-level Escape handlers
107
+ registered earlier (CameraFeed's exit-fullscreen key) see the press
108
+ as already consumed by the menu. */
95
109
  const onKeyDown = (e: KeyboardEvent) => {
96
110
  if (e.key === "Escape") {
111
+ e.preventDefault();
97
112
  e.stopPropagation();
98
113
  setOpen(false);
99
114
  triggerRef.current?.focus();
100
115
  }
101
116
  };
117
+ /* The portal root changes with fullscreen and no resize may fire (kiosk,
118
+ F11), so a menu left open would remount elsewhere with stale
119
+ coordinates. */
120
+ const onFullscreenChange = () => {
121
+ setOpen(false);
122
+ triggerRef.current?.focus();
123
+ };
102
124
  const onPointerDown = (e: PointerEvent) => {
103
125
  if (
104
126
  !menuRef.current?.contains(e.target as Node) &&
@@ -107,11 +129,15 @@ export function usePortalMenu({ maxWidth, maxHeight, align }: MenuAnchor) {
107
129
  setOpen(false);
108
130
  }
109
131
  };
110
- document.addEventListener("keydown", onKeyDown);
132
+ document.addEventListener("keydown", onKeyDown, true);
111
133
  document.addEventListener("pointerdown", onPointerDown);
134
+ document.addEventListener("fullscreenchange", onFullscreenChange);
135
+ document.addEventListener("webkitfullscreenchange", onFullscreenChange);
112
136
  return () => {
113
- document.removeEventListener("keydown", onKeyDown);
137
+ document.removeEventListener("keydown", onKeyDown, true);
114
138
  document.removeEventListener("pointerdown", onPointerDown);
139
+ document.removeEventListener("fullscreenchange", onFullscreenChange);
140
+ document.removeEventListener("webkitfullscreenchange", onFullscreenChange);
115
141
  };
116
142
  }, [open]);
117
143