@ksp-gonogo/kerbcast-react 1.6.3 → 1.7.0
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/CHANGELOG.md +41 -0
- package/README.md +19 -0
- package/dist/CameraFeed.d.ts +31 -3
- package/dist/CameraFeed.js +86 -49
- package/dist/KerbalFaceFeed.d.ts +82 -0
- package/dist/KerbalFaceFeed.js +118 -0
- package/dist/context.d.ts +27 -1
- package/dist/context.js +68 -2
- package/dist/hooks/useReportDisplaySize.d.ts +23 -0
- package/dist/hooks/useReportDisplaySize.js +71 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +176 -12
- package/src/CameraFeed.tsx +196 -56
- package/src/KerbalFaceFeed.test.tsx +203 -0
- package/src/KerbalFaceFeed.tsx +236 -0
- package/src/context.test.tsx +94 -0
- package/src/context.tsx +98 -1
- package/src/hooks/useReportDisplaySize.test.tsx +166 -0
- package/src/hooks/useReportDisplaySize.ts +91 -0
- package/src/index.ts +12 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.0 - 2026-07-24
|
|
4
|
+
|
|
5
|
+
Crew face cameras, auto-resolution, and camera targeting.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `KerbalFaceFeed`: a single-kerbal face-camera primitive. Renders one crew
|
|
10
|
+
face in a square frame, keyed by `flightId`, and never remounts its `<video>`
|
|
11
|
+
on re-layout. Composable via `children` (name label, badge, custom overlays)
|
|
12
|
+
and an `actions` bar.
|
|
13
|
+
- Low-level auto-resolution primitives, for CUSTOM feed surfaces only (not
|
|
14
|
+
needed for normal use, since the feed components self-measure):
|
|
15
|
+
`useReportDisplaySize`, `createClientDisplaySizes`, `useKerbcastDisplaySizes`,
|
|
16
|
+
the `KerbcastDisplaySizes` type, and a `displaySizes` override on
|
|
17
|
+
`KerbcastProvider`.
|
|
18
|
+
- `showActions?: boolean` (default true) on `CameraFeed` and `KerbalFaceFeed` to
|
|
19
|
+
suppress the hover action bar without affecting resolution reporting.
|
|
20
|
+
- `reportSize?: boolean` (default true) on `KerbalFaceFeed` to opt a
|
|
21
|
+
fixed-resolution face out of auto-resolution.
|
|
22
|
+
- `enableTracking?: boolean` (default false) on `CameraFeed`: a tri-state
|
|
23
|
+
auto-track control (off / active-vessel / target) on pan+zoom cameras,
|
|
24
|
+
highlighted from the server-published `CameraState.trackMode`.
|
|
25
|
+
- `cameraFilter?: (camera) => boolean` on `CameraFeed` to narrow the selectable
|
|
26
|
+
set (picker menu, stepper, auto-latch). Omit to consider every camera.
|
|
27
|
+
- `--kerbcast-action-active` theme token: the fill for every active action-row
|
|
28
|
+
toggle (tracking, quality, PiP, fullscreen, custom). Defaults to
|
|
29
|
+
`var(--kerbcast-accent, #00ff88)`, so one knob recolours them all.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- `KerbalFaceFeed` `size` is now LAYOUT-ONLY. It sets the element's size but no
|
|
34
|
+
longer requests a stream resolution: the primitive always self-measures its
|
|
35
|
+
rendered box and reports that (auto-resolution), so resolution follows how
|
|
36
|
+
large the feed is actually shown.
|
|
37
|
+
- `CameraFeed` with `renderSize="auto"` (the default) now reports its measured
|
|
38
|
+
display size for auto-resolution instead of writing a 16:9 operator render
|
|
39
|
+
size. `renderSize="none"` opts out.
|
|
40
|
+
- `CameraFeed` auto-disables its manual pan/zoom controls while the camera's
|
|
41
|
+
published `trackMode` is not `none` (they no-op against a live track and the
|
|
42
|
+
rate/drag path otherwise jitters).
|
|
43
|
+
|
|
3
44
|
## 0.12.0 - 2026-06-07
|
|
4
45
|
|
|
5
46
|
Initial release. Versioned in lockstep with `@jonpepler/kerbcast` and the
|
package/README.md
CHANGED
|
@@ -73,8 +73,27 @@ An imperative `CameraFeedHandle` (via `ref`) exposes `stepCamera`,
|
|
|
73
73
|
`setPanAxis`, `setZoomRate`, `nudgePan`, and `nudgeZoom` for wiring feeds
|
|
74
74
|
to a gamepad or keyboard.
|
|
75
75
|
|
|
76
|
+
`enableTracking` shows a tri-state auto-track control (off / active-vessel /
|
|
77
|
+
target) on pan+zoom cameras, and `CameraFeed` disables its manual pan/zoom
|
|
78
|
+
while a camera is tracking. `cameraFilter` narrows the selectable cameras
|
|
79
|
+
(picker, stepper, auto-latch). `showActions={false}` hides the whole action
|
|
80
|
+
bar without affecting resolution reporting.
|
|
81
|
+
|
|
82
|
+
## `KerbalFaceFeed`
|
|
83
|
+
|
|
84
|
+
A single-kerbal face-camera primitive: one crew face in a square frame, keyed
|
|
85
|
+
by `flightId`, never remounting its `<video>` on re-layout. Compose a name
|
|
86
|
+
label, badge, or overlay via `children`. `size` is LAYOUT-ONLY: the feed always
|
|
87
|
+
self-measures its rendered box and reports that for auto-resolution, so stream
|
|
88
|
+
quality follows how large it is shown (pass `reportSize={false}` to opt out).
|
|
89
|
+
|
|
76
90
|
## Hooks
|
|
77
91
|
|
|
92
|
+
- `useReportDisplaySize(flightId, ref)`. Self-measure an element and drive the
|
|
93
|
+
client's per-consumer `reportDisplaySize` (auto-resolution). The feed
|
|
94
|
+
primitives use it internally; multiple views of one camera from a client
|
|
95
|
+
collapse to a single MAX report.
|
|
96
|
+
|
|
78
97
|
- `useKerbcastCameras(): CameraState[]`. The live camera registry,
|
|
79
98
|
re-rendering as cameras appear, change, or are destroyed.
|
|
80
99
|
- `useKerbcastStream(flightId): MediaStream | null`. The video stream
|
package/dist/CameraFeed.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
1
|
+
import type { CameraState, KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
2
2
|
/**
|
|
3
3
|
* A consumer-supplied action rendered into the feed's top-right action bar.
|
|
4
4
|
* Lets a host page (e.g. the sidecar's spotlight toggle) inject controls
|
|
@@ -43,6 +43,13 @@ export interface CameraFeedProps {
|
|
|
43
43
|
* buttons). Never called on auto-latch.
|
|
44
44
|
*/
|
|
45
45
|
onSelectCamera?: (flightId: number) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Restrict the cameras this feed can select / step through / auto-latch onto
|
|
48
|
+
* (the picker menu + stepper + fallback). Omit to consider every camera (the
|
|
49
|
+
* default). A host uses it to scope the selectable set — e.g. a part-grid tile
|
|
50
|
+
* offering only part cameras so crew cams stay in the crew bar.
|
|
51
|
+
*/
|
|
52
|
+
cameraFilter?: (camera: CameraState) => boolean;
|
|
46
53
|
/**
|
|
47
54
|
* Called whenever the camera this feed actually displays changes — including
|
|
48
55
|
* auto-latch and fallback picks, not just explicit selection. The argument
|
|
@@ -76,8 +83,12 @@ export interface CameraFeedProps {
|
|
|
76
83
|
*/
|
|
77
84
|
showStandbyIcon?: boolean;
|
|
78
85
|
/**
|
|
79
|
-
* "auto" (default):
|
|
80
|
-
*
|
|
86
|
+
* "auto" (default): the feed self-measures its rendered pixel box and reports
|
|
87
|
+
* it to the sidecar via the per-consumer `reportDisplaySize` (auto-resolution;
|
|
88
|
+
* the sidecar maxes across consumers). "none": no reporting, for a
|
|
89
|
+
* fixed-resolution feed. This drives the AUTO signal, not the operator
|
|
90
|
+
* `setRenderSize` (which, with the quality presets, is the manual cap:
|
|
91
|
+
* effective = min(auto, cap)).
|
|
81
92
|
*/
|
|
82
93
|
renderSize?: "auto" | "none";
|
|
83
94
|
/** Message shown when no cameras are available. */
|
|
@@ -104,6 +115,16 @@ export interface CameraFeedProps {
|
|
|
104
115
|
* false.
|
|
105
116
|
*/
|
|
106
117
|
enableQualityControl?: boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Show a built-in auto-track (crosshair) control, only on pan+zoom cameras.
|
|
120
|
+
* A tri-state toggle group (off / track active vessel / track target): the
|
|
121
|
+
* camera aims itself at the chosen moving vessel (and auto-zooms) with no kOS.
|
|
122
|
+
* State is SERVER-authoritative: the highlight reflects `CameraState.trackMode`
|
|
123
|
+
* published by the sidecar, never the local click, so two browsers agree.
|
|
124
|
+
* While tracking, the manual pan/zoom controls are disabled (the aim loop owns
|
|
125
|
+
* the gimbal + FoV). Default false.
|
|
126
|
+
*/
|
|
127
|
+
enableTracking?: boolean;
|
|
107
128
|
/**
|
|
108
129
|
* Consumer-injected action buttons, rendered left of the built-in
|
|
109
130
|
* fullscreen/PiP controls in the top-right action bar.
|
|
@@ -115,6 +136,13 @@ export interface CameraFeedProps {
|
|
|
115
136
|
* button so it sits in the corner.
|
|
116
137
|
*/
|
|
117
138
|
trailingActions?: FeedAction[];
|
|
139
|
+
/**
|
|
140
|
+
* Render the feed's action UI (the top-right action bar: camera stepper,
|
|
141
|
+
* custom actions, quality / fullscreen / PiP). Default true. Set false to
|
|
142
|
+
* suppress the whole bar (e.g. a small tile where hover controls add
|
|
143
|
+
* nothing). Does not affect display-size reporting.
|
|
144
|
+
*/
|
|
145
|
+
showActions?: boolean;
|
|
118
146
|
/**
|
|
119
147
|
* Override how the displayed video stream is sourced for the resolved
|
|
120
148
|
* flightId. Omit (the default) to use the built-in `useKerbcastStream` —
|
package/dist/CameraFeed.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { PanZoomController, QualityPreset } from "@ksp-gonogo/kerbcast";
|
|
2
|
+
import { PanZoomController, QualityPreset, TrackMode } from "@ksp-gonogo/kerbcast";
|
|
3
3
|
import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from "react";
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import styled, { css } from "styled-components";
|
|
@@ -8,6 +8,7 @@ import { KerbcastProvider, useKerbcastClient } from "./context";
|
|
|
8
8
|
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
9
9
|
import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
10
10
|
import { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
11
|
+
import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
11
12
|
import { StandbyIcon } from "./StandbyIcon";
|
|
12
13
|
import { isCameraDestroyed } from "./lifecycle";
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
@@ -109,10 +110,6 @@ function usePortalMenu({ maxWidth, maxHeight, align }) {
|
|
|
109
110
|
}, []);
|
|
110
111
|
return { open, toggle, close, position, menuRef, triggerRef };
|
|
111
112
|
}
|
|
112
|
-
/** Round n to the nearest even integer, minimum 2 (H.264 chroma requirement). */
|
|
113
|
-
function toEvenPx(n) {
|
|
114
|
-
return Math.max(2, Math.round(n / 2) * 2);
|
|
115
|
-
}
|
|
116
113
|
/*
|
|
117
114
|
* Viewer quality presets, in menu order. Scales mirror the sidecar's
|
|
118
115
|
* QualityPreset mapping (fractions of the camera's operator render size);
|
|
@@ -149,9 +146,14 @@ function exitFullscreen() {
|
|
|
149
146
|
// ---------------------------------------------------------------------------
|
|
150
147
|
// Inner component (reads from context)
|
|
151
148
|
// ---------------------------------------------------------------------------
|
|
152
|
-
const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, onDisplayedCameraChange, showDebugInfo = false, showStatic, inFlight: inFlightProp, showStandbyIcon = true, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, actions, trailingActions, useStream, }, ref) {
|
|
149
|
+
const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, cameraFilter, onDisplayedCameraChange, showDebugInfo = false, showStatic, inFlight: inFlightProp, showStandbyIcon = true, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, enableTracking = false, actions, trailingActions, showActions = true, useStream, }, ref) {
|
|
153
150
|
const client = useKerbcastClient();
|
|
154
|
-
|
|
151
|
+
// The selectable set: every live camera, optionally narrowed by the host's
|
|
152
|
+
// cameraFilter (e.g. a part-grid tile offering only part cams). Drives the
|
|
153
|
+
// picker menu, the stepper, and the auto-latch fallback so none can land on
|
|
154
|
+
// a filtered-out camera.
|
|
155
|
+
const allCameras = useKerbcastCameras();
|
|
156
|
+
const cameras = useMemo(() => (cameraFilter ? allCameras.filter(cameraFilter) : allCameras), [allCameras, cameraFilter]);
|
|
155
157
|
const inFlightFromClient = useKerbcastInFlight();
|
|
156
158
|
const inFlight = inFlightProp ?? inFlightFromClient;
|
|
157
159
|
const outOfFlight = inFlight === false;
|
|
@@ -316,6 +318,13 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
316
318
|
const isDestroyed = camera ? isCameraDestroyed(camera) : false;
|
|
317
319
|
const showPan = camera?.supportsPan && !isDestroyed;
|
|
318
320
|
const showZoom = camera?.supportsZoom && !isDestroyed;
|
|
321
|
+
// Auto-track state is server-authoritative: driven purely by the published
|
|
322
|
+
// trackMode (absent -> none), never local click. While tracking, the aim
|
|
323
|
+
// loop owns the gimbal + FoV, so the manual pan/zoom controls are disabled
|
|
324
|
+
// (they jitter against the track and are no-ops in practice).
|
|
325
|
+
const trackMode = camera?.trackMode ?? TrackMode.None;
|
|
326
|
+
const tracking = trackMode !== TrackMode.None;
|
|
327
|
+
const manualDisabled = tracking;
|
|
319
328
|
const supportsPitch = !!camera && camera.panPitchMax - camera.panPitchMin > 0;
|
|
320
329
|
// -------------------------------------------------------------------------
|
|
321
330
|
// Camera selection callbacks (Next/Prev, picker)
|
|
@@ -441,36 +450,12 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
441
450
|
},
|
|
442
451
|
}), [stepCamera, showZoom, showPan, supportsPitch]);
|
|
443
452
|
// -------------------------------------------------------------------------
|
|
444
|
-
//
|
|
453
|
+
// Display-size reporting (auto-resolution). The feed self-measures its
|
|
454
|
+
// rendered box and reports it per-consumer; the sidecar maxes across
|
|
455
|
+
// consumers. Reports the real w x h (part cams are not square). Disabled by
|
|
456
|
+
// `renderSize="none"`. See useReportDisplaySize for debounce / bucketing.
|
|
445
457
|
// -------------------------------------------------------------------------
|
|
446
|
-
|
|
447
|
-
useEffect(() => {
|
|
448
|
-
if (renderSize !== "auto" || flightId === null)
|
|
449
|
-
return;
|
|
450
|
-
const el = wrapRef.current;
|
|
451
|
-
if (!el)
|
|
452
|
-
return;
|
|
453
|
-
const cam = client.camera(flightId);
|
|
454
|
-
const observer = new ResizeObserver((entries) => {
|
|
455
|
-
const entry = entries[0];
|
|
456
|
-
if (!entry)
|
|
457
|
-
return;
|
|
458
|
-
const { width } = entry.contentRect;
|
|
459
|
-
if (resizeTimerRef.current !== null)
|
|
460
|
-
clearTimeout(resizeTimerRef.current);
|
|
461
|
-
resizeTimerRef.current = setTimeout(() => {
|
|
462
|
-
const w = toEvenPx(width);
|
|
463
|
-
const h = toEvenPx((width * 9) / 16);
|
|
464
|
-
void cam.setRenderSize(w, h);
|
|
465
|
-
}, 500);
|
|
466
|
-
});
|
|
467
|
-
observer.observe(el);
|
|
468
|
-
return () => {
|
|
469
|
-
observer.disconnect();
|
|
470
|
-
if (resizeTimerRef.current !== null)
|
|
471
|
-
clearTimeout(resizeTimerRef.current);
|
|
472
|
-
};
|
|
473
|
-
}, [client, flightId, renderSize]);
|
|
458
|
+
useReportDisplaySize(flightId, wrapRef, { enabled: renderSize === "auto" });
|
|
474
459
|
// -------------------------------------------------------------------------
|
|
475
460
|
// UI state
|
|
476
461
|
// -------------------------------------------------------------------------
|
|
@@ -512,6 +497,33 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
512
497
|
void client.camera(flightId).setQuality(preset);
|
|
513
498
|
closeQualityMenu();
|
|
514
499
|
}, [client, flightId, closeQualityMenu]);
|
|
500
|
+
// -------------------------------------------------------------------------
|
|
501
|
+
// Auto-track control (opt-in, pan+zoom cameras only). A crosshair button +
|
|
502
|
+
// menu with the two modes. Sends the intent; the highlight follows the
|
|
503
|
+
// server-published trackMode (above), so two browsers agree.
|
|
504
|
+
// -------------------------------------------------------------------------
|
|
505
|
+
const trackingAvailable = enableTracking &&
|
|
506
|
+
flightId !== null &&
|
|
507
|
+
camera !== null &&
|
|
508
|
+
camera.supportsPan === true &&
|
|
509
|
+
camera.supportsZoom === true &&
|
|
510
|
+
!isDestroyed;
|
|
511
|
+
const trackingMenuId = useId();
|
|
512
|
+
const trackingMenu = usePortalMenu({
|
|
513
|
+
maxWidth: QUALITY_MENU_MAX_WIDTH,
|
|
514
|
+
maxHeight: QUALITY_MENU_MAX_HEIGHT,
|
|
515
|
+
align: "end",
|
|
516
|
+
});
|
|
517
|
+
const closeTrackingMenu = trackingMenu.close;
|
|
518
|
+
const selectTrack = useCallback((mode) => {
|
|
519
|
+
if (flightId === null)
|
|
520
|
+
return;
|
|
521
|
+
// Tri-state: clicking the already-active mode hands aiming back (none).
|
|
522
|
+
// Decision reads the server-confirmed trackMode, not a local toggle.
|
|
523
|
+
const next = trackMode === mode ? TrackMode.None : mode;
|
|
524
|
+
void client.setTrackTarget(flightId, next);
|
|
525
|
+
closeTrackingMenu();
|
|
526
|
+
}, [client, flightId, trackMode, closeTrackingMenu]);
|
|
515
527
|
const topOverlay = (_jsxs(TopOverlay, { children: [_jsx(TitleRow, { children: _jsx(TopTitle, { children: hasCameras ? (_jsxs(TitleButton, { ref: cameraMenu.triggerRef, type: "button", "aria-haspopup": "menu", "aria-expanded": cameraMenu.open, "aria-controls": menuId, onClick: cameraMenu.toggle, children: [_jsx(TitleButton__Text, { children: title }), _jsx(ChevronDownIcon, { "aria-hidden": "true" })] })) : (title) }) }), cameraMenu.open &&
|
|
516
528
|
hasCameras &&
|
|
517
529
|
cameraMenu.position &&
|
|
@@ -521,22 +533,32 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
521
533
|
}, children: [cameraLabel(c), " (", c.vesselName, ")", isCameraDestroyed(c) ? " - signal lost" : ""] }, c.flightId))) }), document.body), showDebugInfo &&
|
|
522
534
|
(camera ? (_jsxs(TopMeta, { children: [camera.vesselName, " \u00B7 ", camera.renderWidth, "\u00D7", camera.renderHeight, bitrateLabel, adaptiveLabel] })) : (_jsx(TopMeta, { children: "no cameras on this vessel" })))] }));
|
|
523
535
|
const renderAction = (a) => (_jsx(OverlayIconButton, { type: "button", "aria-label": a.label, "aria-pressed": a.active ?? undefined, title: a.label, "$active": a.active ?? false, onClick: a.onClick, children: a.icon }, a.id));
|
|
524
|
-
const builtInActions = flightId !== null &&
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
(
|
|
528
|
-
|
|
536
|
+
const builtInActions = flightId !== null &&
|
|
537
|
+
(pipAvailable || fullscreenAvailable || qualityAvailable || trackingAvailable);
|
|
538
|
+
const hasActionBar = showActions &&
|
|
539
|
+
(hasCameras ||
|
|
540
|
+
(actions && actions.length > 0) ||
|
|
541
|
+
(trailingActions && trailingActions.length > 0) ||
|
|
542
|
+
builtInActions);
|
|
529
543
|
const actionBar = hasActionBar ? (_jsxs(ActionBar, { children: [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" })] })), actions?.map(renderAction), qualityAvailable && (_jsxs(OverlayIconButton, { ref: qualityMenu.triggerRef, type: "button", "aria-label": "Quality", "aria-haspopup": "menu", "aria-expanded": qualityMenu.open, "aria-controls": qualityMenuId, title: qualityThrottled
|
|
530
544
|
? "Quality (throttled by adaptive performance)"
|
|
531
|
-
: "Quality", "$active": qualityMenu.open, onClick: qualityMenu.toggle, children: [_jsx(QualityIcon, {}), qualityThrottled && _jsx(ThrottledDot, { "aria-hidden": "true" })] })),
|
|
545
|
+
: "Quality", "$active": qualityMenu.open, onClick: qualityMenu.toggle, children: [_jsx(QualityIcon, {}), qualityThrottled && _jsx(ThrottledDot, { "aria-hidden": "true" })] })), trackingAvailable && (_jsx(OverlayIconButton, { ref: trackingMenu.triggerRef, type: "button", "aria-label": "Auto-track", "aria-haspopup": "menu", "aria-expanded": trackingMenu.open, "aria-controls": trackingMenuId, "aria-pressed": tracking, title: tracking
|
|
546
|
+
? trackMode === TrackMode.Target
|
|
547
|
+
? "Auto-tracking target"
|
|
548
|
+
: "Auto-tracking active vessel"
|
|
549
|
+
: "Auto-track a vessel", "$active": tracking || trackingMenu.open, onClick: trackingMenu.toggle, children: _jsx(CrosshairIcon, {}) })), flightId !== null && pipAvailable && (_jsx(OverlayIconButton, { type: "button", "aria-label": isPip ? "Exit picture in picture" : "Picture in picture", "aria-pressed": isPip, title: isPip ? "Exit picture in picture" : "Picture in picture", "$active": isPip, onClick: togglePip, children: _jsx(PictureInPictureIcon, {}) })), flightId !== null && fullscreenAvailable && (_jsx(OverlayIconButton, { type: "button", "aria-label": isFullscreen ? "Exit fullscreen" : "Enter fullscreen", "aria-pressed": isFullscreen, title: isFullscreen ? "Exit fullscreen" : "Enter fullscreen", "$active": isFullscreen, onClick: toggleFullscreen, children: isFullscreen ? _jsx(FullscreenExitIcon, {}) : _jsx(FullscreenEnterIcon, {}) })), trailingActions?.map(renderAction)] })) : null;
|
|
532
550
|
return (_jsx(Stage, { ref: wrapRef, "$pinned": chromePinned, children: flightId === null ? (_jsxs(_Fragment, { children: [_jsx(Empty, { children: emptyMessage }), topOverlay, actionBar] })) : (_jsxs(_Fragment, { children: [_jsx(StyledVideo, { ref: videoRef, autoPlay: true, playsInline: true, muted: true, controls: false, onClick: () => setChromePinned((v) => !v) }), topOverlay, actionBar, qualityAvailable &&
|
|
533
551
|
qualityMenu.open &&
|
|
534
552
|
qualityMenu.position &&
|
|
535
553
|
camera &&
|
|
536
|
-
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),
|
|
554
|
+
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 &&
|
|
555
|
+
trackingMenu.open &&
|
|
556
|
+
trackingMenu.position &&
|
|
557
|
+
camera &&
|
|
558
|
+
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), 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"] })] })), 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) => {
|
|
537
559
|
if (e.detail === 0)
|
|
538
560
|
controllerRef.current?.nudgeZoom(-1);
|
|
539
|
-
}, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, onChange: (e) => {
|
|
561
|
+
}, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
|
|
540
562
|
const v = Number(e.target.value);
|
|
541
563
|
controllerRef.current?.fovSliderInput(v);
|
|
542
564
|
}, onPointerDown: () => {
|
|
@@ -545,10 +567,10 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
545
567
|
controllerRef.current?.setFovSliderDragging(false);
|
|
546
568
|
}, onPointerCancel: () => {
|
|
547
569
|
controllerRef.current?.setFovSliderDragging(false);
|
|
548
|
-
} }), _jsx(ZoomButton, { type: "button", "aria-label": "Zoom out", "$pos": "bottom", onPointerDown: () => controllerRef.current?.setZoomRate(-1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
|
|
570
|
+
} }), _jsx(ZoomButton, { type: "button", "aria-label": "Zoom out", "$pos": "bottom", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(-1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
|
|
549
571
|
if (e.detail === 0)
|
|
550
572
|
controllerRef.current?.nudgeZoom(1);
|
|
551
|
-
}, children: "\u2212" })] })), showPan && (_jsxs(PanControl, { role: "group", "aria-label": "Pan camera", children: [_jsx(PanArrow, { type: "button", "$dir": "up", "aria-label": "Pan up", disabled: !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, 1), children: "\u25B2" }), _jsx(PanArrow, { type: "button", "$dir": "down", "aria-label": "Pan down", disabled: !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, -1), children: "\u25BC" }), _jsx(PanArrow, { type: "button", "$dir": "left", "aria-label": "Pan left", onClick: () => controllerRef.current?.nudgePan(-1, 0), children: "\u25C0" }), _jsx(PanArrow, { type: "button", "$dir": "right", "aria-label": "Pan right", onClick: () => controllerRef.current?.nudgePan(1, 0), children: "\u25B6" }), _jsx(PanBall, { "aria-hidden": "true", title: "Drag to pan", onPointerDown: handleBallDown, onPointerMove: handleBallMove, onPointerUp: handleBallUp, onPointerCancel: handleBallUp, style: {
|
|
573
|
+
}, children: "\u2212" })] })), showPan && (_jsxs(PanControl, { role: "group", "aria-label": "Pan camera", "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(PanArrow, { type: "button", "$dir": "up", "aria-label": "Pan up", disabled: manualDisabled || !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, 1), children: "\u25B2" }), _jsx(PanArrow, { type: "button", "$dir": "down", "aria-label": "Pan down", disabled: manualDisabled || !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, -1), children: "\u25BC" }), _jsx(PanArrow, { type: "button", "$dir": "left", "aria-label": "Pan left", disabled: manualDisabled, onClick: () => controllerRef.current?.nudgePan(-1, 0), children: "\u25C0" }), _jsx(PanArrow, { type: "button", "$dir": "right", "aria-label": "Pan right", disabled: manualDisabled, onClick: () => controllerRef.current?.nudgePan(1, 0), children: "\u25B6" }), _jsx(PanBall, { "aria-hidden": "true", title: "Drag to pan", onPointerDown: manualDisabled ? undefined : handleBallDown, onPointerMove: manualDisabled ? undefined : handleBallMove, onPointerUp: manualDisabled ? undefined : handleBallUp, onPointerCancel: manualDisabled ? undefined : handleBallUp, style: {
|
|
552
574
|
transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
|
|
553
575
|
} })] }))] })) }));
|
|
554
576
|
});
|
|
@@ -593,6 +615,10 @@ function FullscreenExitIcon() {
|
|
|
593
615
|
function QualityIcon() {
|
|
594
616
|
return (_jsxs("svg", { ...iconProps, children: [_jsx("path", { d: "M2 4.5h12M2 8h12M2 11.5h12" }), _jsx("circle", { cx: "10.5", cy: "4.5", r: "1.6", fill: "currentColor", stroke: "none" }), _jsx("circle", { cx: "5.5", cy: "8", r: "1.6", fill: "currentColor", stroke: "none" }), _jsx("circle", { cx: "8.5", cy: "11.5", r: "1.6", fill: "currentColor", stroke: "none" })] }));
|
|
595
617
|
}
|
|
618
|
+
/* Auto-track: a crosshair / reticle. */
|
|
619
|
+
function CrosshairIcon() {
|
|
620
|
+
return (_jsxs("svg", { ...iconProps, children: [_jsx("circle", { cx: "8", cy: "8", r: "4.5" }), _jsx("path", { d: "M8 1v2.5M8 12.5V15M1 8h2.5M12.5 8H15" })] }));
|
|
621
|
+
}
|
|
596
622
|
/* Stale badge: fading signal bars (tallest dimmed). */
|
|
597
623
|
function StaleIcon() {
|
|
598
624
|
return (_jsxs("svg", { viewBox: "0 0 16 16", width: 10, height: 10, fill: "currentColor", stroke: "none", "aria-hidden": "true", children: [_jsx("rect", { x: "2", y: "9", width: "2.5", height: "5" }), _jsx("rect", { x: "6.5", y: "6", width: "2.5", height: "8" }), _jsx("rect", { x: "11", y: "3", width: "2.5", height: "11", opacity: "0.35" })] }));
|
|
@@ -613,6 +639,9 @@ const PanControl = styled.div `
|
|
|
613
639
|
transition: opacity 0.15s;
|
|
614
640
|
touch-action: none;
|
|
615
641
|
|
|
642
|
+
/* Greyed + inert while auto-tracking owns the gimbal. */
|
|
643
|
+
${(p) => p.$disabled && "pointer-events: none; filter: grayscale(1) opacity(0.4);"}
|
|
644
|
+
|
|
616
645
|
@media (prefers-reduced-motion: reduce) {
|
|
617
646
|
transition: none;
|
|
618
647
|
}
|
|
@@ -692,6 +721,9 @@ const ZoomControlsWrap = styled.div `
|
|
|
692
721
|
opacity: 0;
|
|
693
722
|
transition: opacity 0.15s;
|
|
694
723
|
|
|
724
|
+
/* Greyed + inert while auto-tracking owns the FoV. */
|
|
725
|
+
${(p) => p.$disabled && "pointer-events: none; filter: grayscale(1) opacity(0.4);"}
|
|
726
|
+
|
|
695
727
|
@media (prefers-reduced-motion: reduce) {
|
|
696
728
|
transition: none;
|
|
697
729
|
}
|
|
@@ -990,12 +1022,17 @@ const OverlayIconButton = styled.button `
|
|
|
990
1022
|
align-items: center;
|
|
991
1023
|
justify-content: center;
|
|
992
1024
|
padding: 0;
|
|
1025
|
+
/* Active-toggle fill (quality / tracking / PiP / fullscreen / custom actions
|
|
1026
|
+
all share this button). Its own token so a consumer can recolour just the
|
|
1027
|
+
action-row highlight without touching the general accent; defaults to
|
|
1028
|
+
--kerbcast-accent, so the sidecar web page (which maps its accent onto that)
|
|
1029
|
+
is unchanged. */
|
|
993
1030
|
background: ${(p) => p.$active
|
|
994
|
-
? "var(--kerbcast-accent, #00ff88)"
|
|
1031
|
+
? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
|
|
995
1032
|
: "rgba(0, 0, 0, 0.5)"};
|
|
996
1033
|
border: 1px solid
|
|
997
1034
|
${(p) => p.$active
|
|
998
|
-
? "var(--kerbcast-accent, #00ff88)"
|
|
1035
|
+
? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
|
|
999
1036
|
: "rgba(255, 255, 255, 0.3)"};
|
|
1000
1037
|
border-radius: 3px;
|
|
1001
1038
|
color: ${(p) => (p.$active ? "#000" : "#fff")};
|
|
@@ -1005,7 +1042,7 @@ const OverlayIconButton = styled.button `
|
|
|
1005
1042
|
@media (hover: hover) {
|
|
1006
1043
|
&:hover {
|
|
1007
1044
|
background: ${(p) => p.$active
|
|
1008
|
-
? "var(--kerbcast-accent, #00ff88)"
|
|
1045
|
+
? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
|
|
1009
1046
|
: "rgba(0, 0, 0, 0.7)"};
|
|
1010
1047
|
border-color: rgba(255, 255, 255, 0.6);
|
|
1011
1048
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KerbalFaceFeed — the shared single-kerbal-face primitive.
|
|
3
|
+
*
|
|
4
|
+
* Renders ONE kerbal's face-camera stream in a fixed SQUARE frame. It is the
|
|
5
|
+
* reusable building block a crew bar (kerbcast web) or a mission-control
|
|
6
|
+
* surface (gonogo) composes: identity-keyed by `flightId`, it binds the live
|
|
7
|
+
* `MediaStream` to a persistent `<video>` and NEVER remounts that element on
|
|
8
|
+
* re-layout (the "never remount a live feed" rule — the same srcObject-via-ref
|
|
9
|
+
* technique `CameraFeed` uses).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately minimal chrome: just the square live face plus a standby glyph
|
|
12
|
+
* when there is no live signal. Everything else — name label, IVA/EVA badge,
|
|
13
|
+
* click behaviour, a "SIGNAL LOST" treatment — is layered OVER it by the
|
|
14
|
+
* wrapper via `children`, driven by the `onFeedStateChange` signal. A thin
|
|
15
|
+
* `actions` bar (the shared `FeedAction` shape) is rendered top-right when a
|
|
16
|
+
* wrapper supplies one, so declarative controls compose the same way they do
|
|
17
|
+
* on `CameraFeed`.
|
|
18
|
+
*/
|
|
19
|
+
import { FeedAction, CameraStreamHook } from "./CameraFeed";
|
|
20
|
+
/**
|
|
21
|
+
* Coarse presentation state a wrapper reacts to for its own fallback/badge:
|
|
22
|
+
* - `live`: a stream is bound and playing.
|
|
23
|
+
* - `no-signal`: no stream yet (connecting, out of flight, or not visible).
|
|
24
|
+
* - `destroyed`: the camera's part/kerbal is gone (the wrapper renders SIGNAL
|
|
25
|
+
* LOST; the last frame stays on-screen because the `<video>` is not cleared).
|
|
26
|
+
*/
|
|
27
|
+
export type KerbalFeedState = "live" | "no-signal" | "destroyed";
|
|
28
|
+
export interface KerbalFaceFeedProps {
|
|
29
|
+
/** Wire-id (`CameraState.flightId`) of the kerbal face camera to display. */
|
|
30
|
+
flightId: number;
|
|
31
|
+
/**
|
|
32
|
+
* Explicit square side in px, for LAYOUT only. When omitted the feed fills
|
|
33
|
+
* its parent's width at a 1:1 aspect ratio. This does NOT drive resolution:
|
|
34
|
+
* the primitive always self-measures its rendered box and reports that to the
|
|
35
|
+
* sidecar (auto-resolution). Pass it to fix the element's size, not to
|
|
36
|
+
* request a stream resolution.
|
|
37
|
+
*/
|
|
38
|
+
size?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Report the self-measured display size to the sidecar to drive
|
|
41
|
+
* auto-resolution. Default true. Set false for a fixed-resolution feed that
|
|
42
|
+
* should not participate in auto-res. Independent of {@link showActions}
|
|
43
|
+
* (a resolution concern, not a UI one).
|
|
44
|
+
*/
|
|
45
|
+
reportSize?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Consumer action buttons, rendered as a minimal top-right bar (the shared
|
|
48
|
+
* {@link FeedAction} shape). Omit for no bar — the common case, since a crew
|
|
49
|
+
* bar usually composes its controls in `children` instead.
|
|
50
|
+
*/
|
|
51
|
+
actions?: FeedAction[];
|
|
52
|
+
/**
|
|
53
|
+
* Render the feed's action UI (the top-right action bar). Default true. Set
|
|
54
|
+
* false to suppress the bar entirely (e.g. a tiny avatar where hover controls
|
|
55
|
+
* add nothing). Does not affect display-size reporting.
|
|
56
|
+
*/
|
|
57
|
+
showActions?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Overlay content layered OVER the square face: name label, IVA/EVA badge,
|
|
60
|
+
* click target, a custom destroyed treatment. Absolutely positioned to fill
|
|
61
|
+
* the frame; the primitive keeps its own chrome out of the way.
|
|
62
|
+
*/
|
|
63
|
+
children?: React.ReactNode;
|
|
64
|
+
/**
|
|
65
|
+
* Fired whenever the derived feed state changes, so the wrapper can swap its
|
|
66
|
+
* fallback/badge without polling. Fires once on mount with the initial state.
|
|
67
|
+
*/
|
|
68
|
+
onFeedStateChange?: (state: KerbalFeedState) => void;
|
|
69
|
+
/**
|
|
70
|
+
* Draw the built-in standby glyph while not `live`. Default true. Set false
|
|
71
|
+
* when the wrapper renders its own out-of-signal overlay (so it isn't drawn
|
|
72
|
+
* twice); the frame then just stays dark / holds the last frame.
|
|
73
|
+
*/
|
|
74
|
+
showStandby?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Override how the stream is sourced (delayed playout, alternate transport).
|
|
77
|
+
* Must be a stable reference passed consistently across renders — it is
|
|
78
|
+
* called as a hook. Omit to use the built-in {@link useKerbcastStream}.
|
|
79
|
+
*/
|
|
80
|
+
useStream?: CameraStreamHook;
|
|
81
|
+
}
|
|
82
|
+
export declare function KerbalFaceFeed({ flightId, size, reportSize, actions, showActions, children, onFeedStateChange, showStandby, useStream, }: KerbalFaceFeedProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* KerbalFaceFeed — the shared single-kerbal-face primitive.
|
|
4
|
+
*
|
|
5
|
+
* Renders ONE kerbal's face-camera stream in a fixed SQUARE frame. It is the
|
|
6
|
+
* reusable building block a crew bar (kerbcast web) or a mission-control
|
|
7
|
+
* surface (gonogo) composes: identity-keyed by `flightId`, it binds the live
|
|
8
|
+
* `MediaStream` to a persistent `<video>` and NEVER remounts that element on
|
|
9
|
+
* re-layout (the "never remount a live feed" rule — the same srcObject-via-ref
|
|
10
|
+
* technique `CameraFeed` uses).
|
|
11
|
+
*
|
|
12
|
+
* Deliberately minimal chrome: just the square live face plus a standby glyph
|
|
13
|
+
* when there is no live signal. Everything else — name label, IVA/EVA badge,
|
|
14
|
+
* click behaviour, a "SIGNAL LOST" treatment — is layered OVER it by the
|
|
15
|
+
* wrapper via `children`, driven by the `onFeedStateChange` signal. A thin
|
|
16
|
+
* `actions` bar (the shared `FeedAction` shape) is rendered top-right when a
|
|
17
|
+
* wrapper supplies one, so declarative controls compose the same way they do
|
|
18
|
+
* on `CameraFeed`.
|
|
19
|
+
*/
|
|
20
|
+
import { useEffect, useRef } from "react";
|
|
21
|
+
import styled from "styled-components";
|
|
22
|
+
import { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
23
|
+
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
24
|
+
import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
25
|
+
import { isCameraDestroyed } from "./lifecycle";
|
|
26
|
+
import { StandbyIcon } from "./StandbyIcon";
|
|
27
|
+
const Frame = styled.div `
|
|
28
|
+
position: relative;
|
|
29
|
+
${(p) => (p.$size != null ? `width: ${p.$size}px; height: ${p.$size}px;` : "width: 100%;")}
|
|
30
|
+
aspect-ratio: 1 / 1;
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
background: #000;
|
|
33
|
+
border-radius: 4px;
|
|
34
|
+
`;
|
|
35
|
+
const Face = styled.video `
|
|
36
|
+
width: 100%;
|
|
37
|
+
height: 100%;
|
|
38
|
+
/* Cover the square: the portrait fills the frame, cropping the excess,
|
|
39
|
+
rather than letterboxing — a face tile reads best edge-to-edge. */
|
|
40
|
+
object-fit: cover;
|
|
41
|
+
display: block;
|
|
42
|
+
`;
|
|
43
|
+
const StandbyLayer = styled.div `
|
|
44
|
+
position: absolute;
|
|
45
|
+
inset: 0;
|
|
46
|
+
display: flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
justify-content: center;
|
|
49
|
+
color: rgba(255, 255, 255, 0.35);
|
|
50
|
+
pointer-events: none;
|
|
51
|
+
`;
|
|
52
|
+
const DestroyedScrim = styled.div `
|
|
53
|
+
position: absolute;
|
|
54
|
+
inset: 0;
|
|
55
|
+
background: rgba(0, 0, 0, 0.45);
|
|
56
|
+
pointer-events: none;
|
|
57
|
+
`;
|
|
58
|
+
const OverlayLayer = styled.div `
|
|
59
|
+
position: absolute;
|
|
60
|
+
inset: 0;
|
|
61
|
+
`;
|
|
62
|
+
const ActionBar = styled.div `
|
|
63
|
+
position: absolute;
|
|
64
|
+
top: 4px;
|
|
65
|
+
right: 4px;
|
|
66
|
+
display: flex;
|
|
67
|
+
gap: 4px;
|
|
68
|
+
z-index: 2;
|
|
69
|
+
`;
|
|
70
|
+
const ActionButton = styled.button `
|
|
71
|
+
display: inline-flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
justify-content: center;
|
|
74
|
+
width: 22px;
|
|
75
|
+
height: 22px;
|
|
76
|
+
padding: 0;
|
|
77
|
+
border: none;
|
|
78
|
+
border-radius: 3px;
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
color: #fff;
|
|
81
|
+
background: ${(p) => (p.$active ? "rgba(255,255,255,0.28)" : "rgba(0,0,0,0.45)")};
|
|
82
|
+
`;
|
|
83
|
+
export function KerbalFaceFeed({ flightId, size, reportSize = true, actions, showActions = true, children, onFeedStateChange, showStandby = true, useStream, }) {
|
|
84
|
+
// Match CameraFeed: the built-in hook (which acquires/releases the
|
|
85
|
+
// subscription slot) runs only when no override is supplied.
|
|
86
|
+
const resolveStream = useStream ?? useKerbcastStream;
|
|
87
|
+
const stream = resolveStream(flightId);
|
|
88
|
+
// The camera's lifecycle comes from the registry snapshot; destroyed cameras
|
|
89
|
+
// linger in the list so the wrapper can show SIGNAL LOST.
|
|
90
|
+
const cameras = useKerbcastCameras();
|
|
91
|
+
const destroyed = cameras.some((c) => c.flightId === flightId && isCameraDestroyed(c));
|
|
92
|
+
const state = destroyed
|
|
93
|
+
? "destroyed"
|
|
94
|
+
: stream
|
|
95
|
+
? "live"
|
|
96
|
+
: "no-signal";
|
|
97
|
+
// Self-measure the square frame and report it to the sidecar (auto-res).
|
|
98
|
+
// Reporting follows the mount lifecycle; the registry collapses multiple
|
|
99
|
+
// feeds of the same kerbal to one MAX report.
|
|
100
|
+
const frameRef = useRef(null);
|
|
101
|
+
useReportDisplaySize(flightId, frameRef, { square: true, enabled: reportSize });
|
|
102
|
+
// Bind the stream to the persistent <video> without remounting it — the
|
|
103
|
+
// element stays mounted across every re-layout / prop change, only its
|
|
104
|
+
// srcObject is (re)assigned. A stall (stream -> null) leaves the last frame.
|
|
105
|
+
const videoRef = useRef(null);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (videoRef.current && stream) {
|
|
108
|
+
videoRef.current.srcObject = stream;
|
|
109
|
+
}
|
|
110
|
+
}, [stream]);
|
|
111
|
+
// Notify the wrapper on every state transition (and once on mount).
|
|
112
|
+
const notify = useRef(onFeedStateChange);
|
|
113
|
+
notify.current = onFeedStateChange;
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
notify.current?.(state);
|
|
116
|
+
}, [state]);
|
|
117
|
+
return (_jsxs(Frame, { ref: frameRef, "$size": size, "data-testid": "kerbal-face-feed", "data-feed-state": state, children: [_jsx(Face, { ref: videoRef, autoPlay: true, playsInline: true, muted: true, controls: false }), state === "destroyed" && _jsx(DestroyedScrim, {}), showStandby && state !== "live" && (_jsx(StandbyLayer, { children: _jsx(StandbyIcon, { size: Math.round((size ?? 96) * 0.32) }) })), showActions && actions && actions.length > 0 && (_jsx(ActionBar, { children: actions.map((a) => (_jsx(ActionButton, { type: "button", "aria-label": a.label, title: a.label, "aria-pressed": a.active, "$active": a.active, onClick: a.onClick, children: a.icon }, a.id))) })), children && _jsx(OverlayLayer, { children: children })] }));
|
|
118
|
+
}
|