@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 +12 -0
- package/dist/CameraFeed.js +85 -13
- package/dist/FeedActionBar.d.ts +7 -0
- package/dist/FeedActionBar.js +4 -4
- package/dist/menuPositioning.d.ts +8 -2
- package/dist/menuPositioning.js +30 -5
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +464 -0
- package/src/CameraFeed.tsx +95 -13
- package/src/FeedActionBar.test.ts +22 -0
- package/src/FeedActionBar.tsx +11 -4
- package/src/menuPositioning.ts +31 -5
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.
|
package/dist/CameraFeed.js
CHANGED
|
@@ -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
|
-
|
|
166
|
-
|
|
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))) }),
|
|
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
|
-
|
|
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" : ""] })] }),
|
|
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" })] }),
|
|
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
|
-
}) }),
|
|
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
|
|
1037
|
-
rect) keeps it clear of the
|
|
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
|
|
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;
|
package/dist/FeedActionBar.d.ts
CHANGED
|
@@ -68,6 +68,7 @@ export interface FeedAction {
|
|
|
68
68
|
}
|
|
69
69
|
interface Partitionable {
|
|
70
70
|
stateful?: boolean;
|
|
71
|
+
inline?: boolean;
|
|
71
72
|
order?: number;
|
|
72
73
|
pinnedTrailing?: boolean;
|
|
73
74
|
}
|
|
@@ -97,6 +98,12 @@ export interface FeedActionBarEntry {
|
|
|
97
98
|
id: string;
|
|
98
99
|
label: string;
|
|
99
100
|
stateful?: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Keeps a non-stateful entry out of the ⋮ menu without changing the split:
|
|
103
|
+
* it still counts as non-stateful toward the overflow threshold, so the
|
|
104
|
+
* rest of the bar lays out exactly as it would without the flag.
|
|
105
|
+
*/
|
|
106
|
+
inline?: boolean;
|
|
100
107
|
order?: number;
|
|
101
108
|
pinnedTrailing?: boolean;
|
|
102
109
|
render: () => React.ReactNode;
|
package/dist/FeedActionBar.js
CHANGED
|
@@ -29,7 +29,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
29
29
|
import { Fragment, useId } from "react";
|
|
30
30
|
import { createPortal } from "react-dom";
|
|
31
31
|
import styled from "styled-components";
|
|
32
|
-
import { usePortalMenu } from "./menuPositioning";
|
|
32
|
+
import { menuPortalRoot, usePortalMenu } from "./menuPositioning";
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
34
34
|
// Partition logic (pure, unit-tested independently of any rendering)
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
@@ -56,8 +56,8 @@ export function partitionActionBarEntries(entries) {
|
|
|
56
56
|
return { primary: overflowable, overflow: [], pinnedTrailing };
|
|
57
57
|
}
|
|
58
58
|
return {
|
|
59
|
-
primary: overflowable.filter((e) => e.stateful),
|
|
60
|
-
overflow: nonStateful,
|
|
59
|
+
primary: overflowable.filter((e) => e.stateful || e.inline),
|
|
60
|
+
overflow: nonStateful.filter((e) => !e.inline),
|
|
61
61
|
pinnedTrailing,
|
|
62
62
|
};
|
|
63
63
|
}
|
|
@@ -108,7 +108,7 @@ export function FeedActionBar({ leading, entries, className }) {
|
|
|
108
108
|
// the entry's own button click handler, so this closes the
|
|
109
109
|
// menu once the action has actually fired rather than
|
|
110
110
|
// pre-empting it.
|
|
111
|
-
_jsxs(OverflowMenuItem, { onClick: closeOverflowMenu, children: [e.render(), _jsx(OverflowMenuItemLabel, { "aria-hidden": "true", children: e.label })] }, e.id))) }),
|
|
111
|
+
_jsxs(OverflowMenuItem, { onClick: closeOverflowMenu, children: [e.render(), _jsx(OverflowMenuItemLabel, { "aria-hidden": "true", children: e.label })] }, e.id))) }), menuPortalRoot()), pinnedTrailing.map((e) => (_jsx(Fragment, { children: e.render() }, e.id)))] }));
|
|
112
112
|
}
|
|
113
113
|
// ---------------------------------------------------------------------------
|
|
114
114
|
// Icons
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared geometry + behaviour for a
|
|
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;
|
package/dist/menuPositioning.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared geometry + behaviour for a
|
|
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
|
|
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.
|
|
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.
|
|
48
|
+
"@ksp-gonogo/kerbcast": "^1.12.0-rc.2"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"react": "^18 || ^19",
|
package/src/CameraFeed.test.tsx
CHANGED
|
@@ -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
|
+
});
|
package/src/CameraFeed.tsx
CHANGED
|
@@ -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
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2031
|
-
rect) keeps it clear of the
|
|
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
|
|
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 },
|
package/src/FeedActionBar.tsx
CHANGED
|
@@ -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
|
-
|
|
254
|
+
menuPortalRoot(),
|
|
248
255
|
)}
|
|
249
256
|
{pinnedTrailing.map((e) => (
|
|
250
257
|
<Fragment key={e.id}>{e.render()}</Fragment>
|
package/src/menuPositioning.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared geometry + behaviour for a
|
|
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
|
|
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
|
|