@ksp-gonogo/kerbcast-react 1.6.2 → 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 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
@@ -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): ResizeObserver drives `setRenderSize` at a 16:9 crop,
80
- * debounced 500 ms. "none": no render-size feedback.
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` —
@@ -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
- const cameras = useKerbcastCameras();
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
- // Render-size feedback (ResizeObserver, 500 ms debounce, 16:9 crop)
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
- const resizeTimerRef = useRef(null);
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 && (pipAvailable || fullscreenAvailable || qualityAvailable);
525
- const hasActionBar = hasCameras ||
526
- (actions && actions.length > 0) ||
527
- (trailingActions && trailingActions.length > 0) ||
528
- builtInActions;
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" })] })), 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;
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), 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, { children: [_jsx(ZoomButton, { type: "button", "aria-label": "Zoom in", "$pos": "top", onPointerDown: () => controllerRef.current?.setZoomRate(1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
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
+ }