@ksp-gonogo/kerbcast-react 1.8.1 → 1.9.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.
@@ -116,6 +116,25 @@ export interface CameraFeedProps {
116
116
  * the gimbal + FoV). Default false.
117
117
  */
118
118
  enableTracking?: boolean;
119
+ /**
120
+ * Stand down the built-in MANUAL camera controls: the pan ball and the
121
+ * zoom/FoV controls disappear, their in-flight rates are zeroed, and the
122
+ * imperative handle's pan/zoom methods become no-ops (so a physical
123
+ * controller wired to the handle can't reach the camera either).
124
+ *
125
+ * "Manual" in the sense this file already uses it - a human directly driving
126
+ * the gimbal and optics - and named for that intent rather than for today's
127
+ * specific surfaces, so anything added later that hand-flies the camera
128
+ * stands down under this flag too. Auto-track is NOT manual and stays live:
129
+ * the aim loop runs game-side, so it keeps working when a host takes the
130
+ * hand-flown path away. Feed affordances are untouched as well - action bar,
131
+ * recording, fullscreen, PiP, quality.
132
+ *
133
+ * For a host that owns manual control itself, e.g. a signal-delay setpoint
134
+ * surface where live pan/zoom would be steering at where the craft is now
135
+ * while watching where it was a light-time ago. Default false.
136
+ */
137
+ disableManualControls?: boolean;
119
138
  /**
120
139
  * Show a built-in REC control in the action bar: a STATEFUL toggle (never
121
140
  * overflow-eligible, per the #6 spec) that starts/stops a client-side
@@ -59,7 +59,7 @@ function exitFullscreen() {
59
59
  // ---------------------------------------------------------------------------
60
60
  // Inner component (reads from context)
61
61
  // ---------------------------------------------------------------------------
62
- 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, enableRecording = false, recordFullResolution = false, actions, trailingActions, showActions = true, useStream, }, ref) {
62
+ 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, disableManualControls = false, enableRecording = false, recordFullResolution = false, actions, trailingActions, showActions = true, useStream, }, ref) {
63
63
  const client = useKerbcastClient();
64
64
  // Always called (rules of hooks); `enableRecording` only gates whether the
65
65
  // REC entry/badge render, not whether the store is subscribed.
@@ -232,8 +232,11 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
232
232
  }
233
233
  }, []);
234
234
  const isDestroyed = camera ? isCameraDestroyed(camera) : false;
235
- const showPan = camera?.supportsPan && !isDestroyed;
236
- const showZoom = camera?.supportsZoom && !isDestroyed;
235
+ /* Gating here rather than at the render sites covers the whole manual
236
+ path at once: the controls unmount, the effects below zero any in-flight
237
+ rate, and the imperative handle's pan/zoom methods no-op. */
238
+ const showPan = camera?.supportsPan && !isDestroyed && !disableManualControls;
239
+ const showZoom = camera?.supportsZoom && !isDestroyed && !disableManualControls;
237
240
  // Auto-track state is server-authoritative: driven purely by the published
238
241
  // trackMode (absent -> none), never local click. While tracking, the aim
239
242
  // loop owns the gimbal + FoV, so the manual pan/zoom controls are disabled
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksp-gonogo/kerbcast-react",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
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.8.1"
48
+ "@ksp-gonogo/kerbcast": "^1.9.0"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": "^18 || ^19",
@@ -272,6 +272,7 @@ function renderFeed(
272
272
  renderSize?: "auto" | "none";
273
273
  enableQualityControl?: boolean;
274
274
  enableTracking?: boolean;
275
+ disableManualControls?: boolean;
275
276
  enableRecording?: boolean;
276
277
  recordFullResolution?: boolean;
277
278
  enableFullscreen?: boolean;
@@ -2014,6 +2015,97 @@ describe("CameraFeed - tracking (crosshair)", () => {
2014
2015
  expect((getByLabelText("Zoom in") as HTMLButtonElement).disabled).toBe(false);
2015
2016
  expect((getByLabelText("Pan left") as HTMLButtonElement).disabled).toBe(false);
2016
2017
  });
2018
+
2019
+ // -------------------------------------------------------------------------
2020
+ // disableManualControls: a host taking over the hand-flown path
2021
+ // -------------------------------------------------------------------------
2022
+
2023
+ it("shows the manual pan + zoom controls by default", async () => {
2024
+ const { client } = await buildConnectedSource([panZoomCam()]);
2025
+ const { queryByLabelText } = renderFeed(client, { flightId: 42 });
2026
+ expect(queryByLabelText("Zoom in")).not.toBeNull();
2027
+ expect(queryByLabelText("Pan camera")).not.toBeNull();
2028
+ });
2029
+
2030
+ it("removes the manual pan + zoom controls when disableManualControls", async () => {
2031
+ const { client } = await buildConnectedSource([panZoomCam()]);
2032
+ const { queryByLabelText } = renderFeed(client, {
2033
+ flightId: 42,
2034
+ disableManualControls: true,
2035
+ });
2036
+ // Gone, not merely disabled: a host owning control shouldn't have to
2037
+ // occlude ours.
2038
+ expect(queryByLabelText("Zoom in")).toBeNull();
2039
+ expect(queryByLabelText("Zoom out")).toBeNull();
2040
+ expect(queryByLabelText("Pan camera")).toBeNull();
2041
+ expect(queryByLabelText("Pan left")).toBeNull();
2042
+ });
2043
+
2044
+ it("keeps auto-track live when disableManualControls: the aim loop is not manual", async () => {
2045
+ const { client } = await buildConnectedSource([panZoomCam()]);
2046
+ const { queryByLabelText } = renderFeed(client, {
2047
+ flightId: 42,
2048
+ enableTracking: true,
2049
+ disableManualControls: true,
2050
+ });
2051
+ expect(queryByLabelText(/track/i)).not.toBeNull();
2052
+ });
2053
+
2054
+ const panRates = (sidecar: MockSidecar) =>
2055
+ sidecar.commands.filter((c) => c.type === "set-pan-rate");
2056
+
2057
+ it("no-ops the imperative pan/zoom handle when disableManualControls", async () => {
2058
+ // Covers the serial-controller path: a host that hides the on-screen
2059
+ // controls must not be reachable through the handle either.
2060
+ const { client, sidecar } = await buildConnectedSource([panZoomCam()]);
2061
+
2062
+ const handleRef = createRef<CameraFeedHandle>();
2063
+ renderFeed(client, {
2064
+ flightId: 42,
2065
+ disableManualControls: true,
2066
+ ref: handleRef,
2067
+ });
2068
+
2069
+ await act(async () => {
2070
+ handleRef.current?.setPanAxis("yaw", 1);
2071
+ handleRef.current?.nudgePan(1, 0);
2072
+ handleRef.current?.setZoomRate(1);
2073
+ handleRef.current?.nudgeZoom(1);
2074
+ });
2075
+
2076
+ expect(panRates(sidecar)).toHaveLength(0);
2077
+ });
2078
+
2079
+ it("zeroes an in-flight pan rate when disableManualControls flips on", async () => {
2080
+ const { client, sidecar } = await buildConnectedSource([panZoomCam()]);
2081
+
2082
+ const handleRef = createRef<CameraFeedHandle>();
2083
+ const { rerender } = render(
2084
+ <KerbcastProvider client={client}>
2085
+ <CameraFeed ref={handleRef} flightId={42} />
2086
+ </KerbcastProvider>,
2087
+ );
2088
+
2089
+ await act(async () => {
2090
+ handleRef.current?.setPanAxis("yaw", 1);
2091
+ });
2092
+ expect(panRates(sidecar).length).toBeGreaterThan(0);
2093
+
2094
+ // Handing control over mid-pan must not leave the gimbal running.
2095
+ await act(async () => {
2096
+ rerender(
2097
+ <KerbcastProvider client={client}>
2098
+ <CameraFeed ref={handleRef} flightId={42} disableManualControls />
2099
+ </KerbcastProvider>,
2100
+ );
2101
+ });
2102
+
2103
+ expect(sidecar.lastCommand("set-pan-rate")?.content).toMatchObject({
2104
+ flightId: 42,
2105
+ yawRate: 0,
2106
+ pitchRate: 0,
2107
+ });
2108
+ });
2017
2109
  });
2018
2110
 
2019
2111
  // ---------------------------------------------------------------------------
@@ -220,6 +220,25 @@ export interface CameraFeedProps {
220
220
  * the gimbal + FoV). Default false.
221
221
  */
222
222
  enableTracking?: boolean;
223
+ /**
224
+ * Stand down the built-in MANUAL camera controls: the pan ball and the
225
+ * zoom/FoV controls disappear, their in-flight rates are zeroed, and the
226
+ * imperative handle's pan/zoom methods become no-ops (so a physical
227
+ * controller wired to the handle can't reach the camera either).
228
+ *
229
+ * "Manual" in the sense this file already uses it - a human directly driving
230
+ * the gimbal and optics - and named for that intent rather than for today's
231
+ * specific surfaces, so anything added later that hand-flies the camera
232
+ * stands down under this flag too. Auto-track is NOT manual and stays live:
233
+ * the aim loop runs game-side, so it keeps working when a host takes the
234
+ * hand-flown path away. Feed affordances are untouched as well - action bar,
235
+ * recording, fullscreen, PiP, quality.
236
+ *
237
+ * For a host that owns manual control itself, e.g. a signal-delay setpoint
238
+ * surface where live pan/zoom would be steering at where the craft is now
239
+ * while watching where it was a light-time ago. Default false.
240
+ */
241
+ disableManualControls?: boolean;
223
242
  /**
224
243
  * Show a built-in REC control in the action bar: a STATEFUL toggle (never
225
244
  * overflow-eligible, per the #6 spec) that starts/stops a client-side
@@ -302,6 +321,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
302
321
  enablePictureInPicture = false,
303
322
  enableQualityControl = false,
304
323
  enableTracking = false,
324
+ disableManualControls = false,
305
325
  enableRecording = false,
306
326
  recordFullResolution = false,
307
327
  actions,
@@ -496,8 +516,12 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
496
516
  }, []);
497
517
 
498
518
  const isDestroyed = camera ? isCameraDestroyed(camera) : false;
499
- const showPan = camera?.supportsPan && !isDestroyed;
500
- const showZoom = camera?.supportsZoom && !isDestroyed;
519
+ /* Gating here rather than at the render sites covers the whole manual
520
+ path at once: the controls unmount, the effects below zero any in-flight
521
+ rate, and the imperative handle's pan/zoom methods no-op. */
522
+ const showPan = camera?.supportsPan && !isDestroyed && !disableManualControls;
523
+ const showZoom =
524
+ camera?.supportsZoom && !isDestroyed && !disableManualControls;
501
525
 
502
526
  // Auto-track state is server-authoritative: driven purely by the published
503
527
  // trackMode (absent -> none), never local click. While tracking, the aim