@ksp-gonogo/kerbcast-react 1.6.3 → 1.8.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 +54 -0
- package/README.md +19 -0
- package/dist/CameraFeed.d.ts +63 -19
- package/dist/CameraFeed.js +329 -202
- package/dist/FeedActionBar.d.ts +133 -0
- package/dist/FeedActionBar.js +225 -0
- package/dist/KerbalFaceFeed.d.ts +82 -0
- package/dist/KerbalFaceFeed.js +111 -0
- package/dist/RecordingsTray.d.ts +37 -0
- package/dist/RecordingsTray.js +279 -0
- package/dist/context.d.ts +27 -1
- package/dist/context.js +68 -2
- package/dist/hooks/useRecordings.d.ts +35 -0
- package/dist/hooks/useRecordings.js +33 -0
- package/dist/hooks/useReportDisplaySize.d.ts +23 -0
- package/dist/hooks/useReportDisplaySize.js +71 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +13 -1
- package/dist/menuPositioning.d.ts +20 -0
- package/dist/menuPositioning.js +98 -0
- package/dist/timing.d.ts +4 -0
- package/dist/timing.js +19 -0
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +719 -10
- package/src/CameraFeed.tsx +586 -334
- package/src/FeedActionBar.test.ts +196 -0
- package/src/FeedActionBar.test.tsx +215 -0
- package/src/FeedActionBar.tsx +391 -0
- package/src/KerbalFaceFeed.test.tsx +203 -0
- package/src/KerbalFaceFeed.tsx +214 -0
- package/src/RecordingsTray.test.tsx +288 -0
- package/src/RecordingsTray.tsx +475 -0
- package/src/context.test.tsx +94 -0
- package/src/context.tsx +98 -1
- package/src/hooks/useRecordings.sharedState.test.tsx +117 -0
- package/src/hooks/useRecordings.test.tsx +217 -0
- package/src/hooks/useRecordings.ts +110 -0
- package/src/hooks/useReportDisplaySize.test.tsx +166 -0
- package/src/hooks/useReportDisplaySize.ts +91 -0
- package/src/index.ts +27 -1
- package/src/menuPositioning.ts +126 -0
- package/src/timing.test.ts +34 -0
- package/src/timing.ts +21 -0
package/src/CameraFeed.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
2
|
-
import { PanZoomController, QualityPreset } from "@ksp-gonogo/kerbcast";
|
|
1
|
+
import type { CameraState, KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
2
|
+
import { PanZoomController, QualityPreset, TrackMode } from "@ksp-gonogo/kerbcast";
|
|
3
3
|
import {
|
|
4
4
|
forwardRef,
|
|
5
5
|
useCallback,
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useId,
|
|
8
8
|
useImperativeHandle,
|
|
9
9
|
useMemo,
|
|
10
|
+
useReducer,
|
|
10
11
|
useRef,
|
|
11
12
|
useState,
|
|
12
13
|
} from "react";
|
|
@@ -14,11 +15,23 @@ import { createPortal } from "react-dom";
|
|
|
14
15
|
import styled, { css } from "styled-components";
|
|
15
16
|
import { buildCameraLabeler } from "./cameraLabels";
|
|
16
17
|
import { KerbcastProvider, useKerbcastClient } from "./context";
|
|
18
|
+
import {
|
|
19
|
+
ActionBarRow,
|
|
20
|
+
FeedActionBar,
|
|
21
|
+
OverlayIconButton,
|
|
22
|
+
feedActionToEntry,
|
|
23
|
+
type FeedAction,
|
|
24
|
+
type FeedActionBarEntry,
|
|
25
|
+
} from "./FeedActionBar";
|
|
17
26
|
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
18
27
|
import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
19
28
|
import { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
29
|
+
import { useRecordings } from "./hooks/useRecordings";
|
|
30
|
+
import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
20
31
|
import { StandbyIcon } from "./StandbyIcon";
|
|
21
32
|
import { isCameraDestroyed } from "./lifecycle";
|
|
33
|
+
import { usePortalMenu } from "./menuPositioning";
|
|
34
|
+
import { formatElapsed, nowMs } from "./timing";
|
|
22
35
|
|
|
23
36
|
// ---------------------------------------------------------------------------
|
|
24
37
|
// Tuning constants
|
|
@@ -28,129 +41,6 @@ const MENU_MAX_WIDTH = 260; // matches CameraMenu's CSS cap
|
|
|
28
41
|
const MENU_MAX_HEIGHT = 300; // matches CameraMenu's min(40vh, 300px) cap
|
|
29
42
|
const QUALITY_MENU_MAX_WIDTH = 220; // matches QualityMenu's CSS cap
|
|
30
43
|
const QUALITY_MENU_MAX_HEIGHT = 220; // matches QualityMenu's min(40vh, 220px) cap
|
|
31
|
-
const MENU_GAP = 4; // trigger-to-menu spacing
|
|
32
|
-
const MENU_EDGE = 8; // minimum inset from the viewport edge
|
|
33
|
-
|
|
34
|
-
/*
|
|
35
|
-
* Geometry of a portaled dropdown: the CSS size caps of the menu (the
|
|
36
|
-
* helper clamps as if the menu fills them) and which edge of the trigger
|
|
37
|
-
* the menu hangs from: "start" lines the menu's left edge up with the
|
|
38
|
-
* trigger's (camera picker), "end" lines the right edges up (quality
|
|
39
|
-
* button at the action bar's corner).
|
|
40
|
-
*/
|
|
41
|
-
interface MenuAnchor {
|
|
42
|
-
maxWidth: number;
|
|
43
|
-
maxHeight: number;
|
|
44
|
-
align: "start" | "end";
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/*
|
|
48
|
-
* Fixed-position style for a portaled menu, anchored to its trigger button.
|
|
49
|
-
* Opens downward by default; flips above the trigger when there is not
|
|
50
|
-
* enough room below but there is above, otherwise clamps to the viewport.
|
|
51
|
-
*/
|
|
52
|
-
function computeMenuPosition(
|
|
53
|
-
trigger: HTMLElement,
|
|
54
|
-
anchor: MenuAnchor,
|
|
55
|
-
): React.CSSProperties {
|
|
56
|
-
const rect = trigger.getBoundingClientRect();
|
|
57
|
-
const vw = window.innerWidth;
|
|
58
|
-
const vh = window.innerHeight;
|
|
59
|
-
const width = Math.min(anchor.maxWidth, vw - 2 * MENU_EDGE);
|
|
60
|
-
/* Inline offset from the aligned viewport edge, clamped so a full-width
|
|
61
|
-
menu still fits inside the opposite edge. */
|
|
62
|
-
const inset = Math.max(MENU_EDGE, vw - width - MENU_EDGE);
|
|
63
|
-
const inline: React.CSSProperties =
|
|
64
|
-
anchor.align === "start"
|
|
65
|
-
? { left: Math.min(Math.max(rect.left, MENU_EDGE), inset) }
|
|
66
|
-
: { right: Math.min(Math.max(vw - rect.right, MENU_EDGE), inset) };
|
|
67
|
-
const menuH = Math.min(0.4 * vh, anchor.maxHeight);
|
|
68
|
-
const fitsBelow = rect.bottom + MENU_GAP + menuH <= vh - MENU_EDGE;
|
|
69
|
-
const fitsAbove = rect.top - MENU_GAP - menuH >= MENU_EDGE;
|
|
70
|
-
if (!fitsBelow && fitsAbove) {
|
|
71
|
-
return { ...inline, bottom: vh - rect.top + MENU_GAP };
|
|
72
|
-
}
|
|
73
|
-
const top = Math.min(
|
|
74
|
-
rect.bottom + MENU_GAP,
|
|
75
|
-
Math.max(MENU_EDGE, vh - MENU_EDGE - menuH),
|
|
76
|
-
);
|
|
77
|
-
return { ...inline, top };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/*
|
|
81
|
-
* Shared behaviour for a dropdown portaled to document.body: fixed position
|
|
82
|
-
* computed from the trigger's rect (so tile overflow cannot clip it),
|
|
83
|
-
* re-anchored on window resize/scroll while open, Escape to close with focus
|
|
84
|
-
* returned to the trigger, and portal-aware outside-pointer-down dismissal
|
|
85
|
-
* (menuRef points at the portaled menu, so clicks inside it stay "inside").
|
|
86
|
-
* One menu at a time falls out of the dismissal: opening another menu's
|
|
87
|
-
* trigger is an outside press for this one.
|
|
88
|
-
*/
|
|
89
|
-
function usePortalMenu({ maxWidth, maxHeight, align }: MenuAnchor) {
|
|
90
|
-
const [open, setOpen] = useState(false);
|
|
91
|
-
const [position, setPosition] = useState<React.CSSProperties | null>(null);
|
|
92
|
-
const menuRef = useRef<HTMLDivElement>(null);
|
|
93
|
-
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
94
|
-
|
|
95
|
-
useEffect(() => {
|
|
96
|
-
if (!open) {
|
|
97
|
-
setPosition(null);
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
const update = () => {
|
|
101
|
-
const trigger = triggerRef.current;
|
|
102
|
-
if (trigger) {
|
|
103
|
-
setPosition(computeMenuPosition(trigger, { maxWidth, maxHeight, align }));
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
update();
|
|
107
|
-
window.addEventListener("resize", update);
|
|
108
|
-
window.addEventListener("scroll", update, true);
|
|
109
|
-
return () => {
|
|
110
|
-
window.removeEventListener("resize", update);
|
|
111
|
-
window.removeEventListener("scroll", update, true);
|
|
112
|
-
};
|
|
113
|
-
}, [open, maxWidth, maxHeight, align]);
|
|
114
|
-
|
|
115
|
-
useEffect(() => {
|
|
116
|
-
if (!open) return;
|
|
117
|
-
const onKeyDown = (e: KeyboardEvent) => {
|
|
118
|
-
if (e.key === "Escape") {
|
|
119
|
-
e.stopPropagation();
|
|
120
|
-
setOpen(false);
|
|
121
|
-
triggerRef.current?.focus();
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
const onPointerDown = (e: PointerEvent) => {
|
|
125
|
-
if (
|
|
126
|
-
!menuRef.current?.contains(e.target as Node) &&
|
|
127
|
-
!triggerRef.current?.contains(e.target as Node)
|
|
128
|
-
) {
|
|
129
|
-
setOpen(false);
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
document.addEventListener("keydown", onKeyDown);
|
|
133
|
-
document.addEventListener("pointerdown", onPointerDown);
|
|
134
|
-
return () => {
|
|
135
|
-
document.removeEventListener("keydown", onKeyDown);
|
|
136
|
-
document.removeEventListener("pointerdown", onPointerDown);
|
|
137
|
-
};
|
|
138
|
-
}, [open]);
|
|
139
|
-
|
|
140
|
-
const toggle = useCallback(() => setOpen((v) => !v), []);
|
|
141
|
-
/** Close after an item pick, returning focus to the trigger. */
|
|
142
|
-
const close = useCallback(() => {
|
|
143
|
-
setOpen(false);
|
|
144
|
-
triggerRef.current?.focus();
|
|
145
|
-
}, []);
|
|
146
|
-
|
|
147
|
-
return { open, toggle, close, position, menuRef, triggerRef };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Round n to the nearest even integer, minimum 2 (H.264 chroma requirement). */
|
|
151
|
-
function toEvenPx(n: number): number {
|
|
152
|
-
return Math.max(2, Math.round(n / 2) * 2);
|
|
153
|
-
}
|
|
154
44
|
|
|
155
45
|
/*
|
|
156
46
|
* Viewer quality presets, in menu order. Scales mirror the sidecar's
|
|
@@ -213,21 +103,11 @@ function exitFullscreen(): void {
|
|
|
213
103
|
// ---------------------------------------------------------------------------
|
|
214
104
|
|
|
215
105
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
106
|
+
* The consumer-supplied action shape, and the shared top-right action bar
|
|
107
|
+
* both `CameraFeed` and `KerbalFaceFeed` render, live in `FeedActionBar`.
|
|
108
|
+
* Re-exported here since existing imports reference `./CameraFeed`.
|
|
219
109
|
*/
|
|
220
|
-
export
|
|
221
|
-
/** Stable identity for the React key. */
|
|
222
|
-
id: string;
|
|
223
|
-
/** Accessible label; used for aria-label and the native tooltip. */
|
|
224
|
-
label: string;
|
|
225
|
-
/** Icon node, sized by the action bar (~14px). */
|
|
226
|
-
icon: React.ReactNode;
|
|
227
|
-
/** Toggle state: renders the button highlighted and sets aria-pressed. */
|
|
228
|
-
active?: boolean;
|
|
229
|
-
onClick: () => void;
|
|
230
|
-
}
|
|
110
|
+
export type { FeedAction } from "./FeedActionBar";
|
|
231
111
|
|
|
232
112
|
/**
|
|
233
113
|
* A hook that yields the live `MediaStream` for a resolved flightId. Must be
|
|
@@ -258,6 +138,13 @@ export interface CameraFeedProps {
|
|
|
258
138
|
* buttons). Never called on auto-latch.
|
|
259
139
|
*/
|
|
260
140
|
onSelectCamera?: (flightId: number) => void;
|
|
141
|
+
/**
|
|
142
|
+
* Restrict the cameras this feed can select / step through / auto-latch onto
|
|
143
|
+
* (the picker menu + stepper + fallback). Omit to consider every camera (the
|
|
144
|
+
* default). A host uses it to scope the selectable set — e.g. a part-grid tile
|
|
145
|
+
* offering only part cameras so crew cams stay in the crew bar.
|
|
146
|
+
*/
|
|
147
|
+
cameraFilter?: (camera: CameraState) => boolean;
|
|
261
148
|
/**
|
|
262
149
|
* Called whenever the camera this feed actually displays changes — including
|
|
263
150
|
* auto-latch and fallback picks, not just explicit selection. The argument
|
|
@@ -291,8 +178,12 @@ export interface CameraFeedProps {
|
|
|
291
178
|
*/
|
|
292
179
|
showStandbyIcon?: boolean;
|
|
293
180
|
/**
|
|
294
|
-
* "auto" (default):
|
|
295
|
-
*
|
|
181
|
+
* "auto" (default): the feed self-measures its rendered pixel box and reports
|
|
182
|
+
* it to the sidecar via the per-consumer `reportDisplaySize` (auto-resolution;
|
|
183
|
+
* the sidecar maxes across consumers). "none": no reporting, for a
|
|
184
|
+
* fixed-resolution feed. This drives the AUTO signal, not the operator
|
|
185
|
+
* `setRenderSize` (which, with the quality presets, is the manual cap:
|
|
186
|
+
* effective = min(auto, cap)).
|
|
296
187
|
*/
|
|
297
188
|
renderSize?: "auto" | "none";
|
|
298
189
|
/** Message shown when no cameras are available. */
|
|
@@ -319,6 +210,39 @@ export interface CameraFeedProps {
|
|
|
319
210
|
* false.
|
|
320
211
|
*/
|
|
321
212
|
enableQualityControl?: boolean;
|
|
213
|
+
/**
|
|
214
|
+
* Show a built-in auto-track (crosshair) control, only on pan+zoom cameras.
|
|
215
|
+
* A tri-state toggle group (off / track active vessel / track target): the
|
|
216
|
+
* camera aims itself at the chosen moving vessel (and auto-zooms) with no kOS.
|
|
217
|
+
* State is SERVER-authoritative: the highlight reflects `CameraState.trackMode`
|
|
218
|
+
* published by the sidecar, never the local click, so two browsers agree.
|
|
219
|
+
* While tracking, the manual pan/zoom controls are disabled (the aim loop owns
|
|
220
|
+
* the gimbal + FoV). Default false.
|
|
221
|
+
*/
|
|
222
|
+
enableTracking?: boolean;
|
|
223
|
+
/**
|
|
224
|
+
* Show a built-in REC control in the action bar: a STATEFUL toggle (never
|
|
225
|
+
* overflow-eligible, per the #6 spec) that starts/stops a client-side
|
|
226
|
+
* recording of this feed via `useRecordings()`. Idle -> click starts
|
|
227
|
+
* (`start(flightId)`); active shows a red dot + a live elapsed timer on the
|
|
228
|
+
* tile -> click stops (`stop(recordingId)`) and the clip lands in the
|
|
229
|
+
* recordings store. Recording is purely client-local (this browser's own
|
|
230
|
+
* MediaRecorder, nothing server-authoritative) and read from the store
|
|
231
|
+
* (`isRecording(flightId)`), so it stays correct if toggled elsewhere (e.g.
|
|
232
|
+
* a REC+ grouped recording). Default false.
|
|
233
|
+
*/
|
|
234
|
+
enableRecording?: boolean;
|
|
235
|
+
/**
|
|
236
|
+
* Record this feed at the operator's full render size for the duration of
|
|
237
|
+
* the built-in REC control's recording (overrides only the display-size
|
|
238
|
+
* demand; still yields to the adaptive framerate shed). Off by default.
|
|
239
|
+
* Threaded straight into `start(flightId, { forceFullResolution })`; has
|
|
240
|
+
* no effect unless `enableRecording` is also set. While the feed is armed
|
|
241
|
+
* (force sent, waiting for the resolution bump) the tile shows an
|
|
242
|
+
* "ARMING" pill under the title in place of the REC badge, then switches
|
|
243
|
+
* to the normal REC badge + elapsed timer once recording actually starts.
|
|
244
|
+
*/
|
|
245
|
+
recordFullResolution?: boolean;
|
|
322
246
|
/**
|
|
323
247
|
* Consumer-injected action buttons, rendered left of the built-in
|
|
324
248
|
* fullscreen/PiP controls in the top-right action bar.
|
|
@@ -326,10 +250,19 @@ export interface CameraFeedProps {
|
|
|
326
250
|
actions?: FeedAction[];
|
|
327
251
|
/**
|
|
328
252
|
* Consumer-injected action buttons rendered at the far end of the action
|
|
329
|
-
* bar
|
|
330
|
-
*
|
|
253
|
+
* bar: the natural home for a close/remove button. Always pinned trailing
|
|
254
|
+
* (never overflow-eligible, never counted toward the overflow threshold),
|
|
255
|
+
* so it sits in the corner as a persistent, single-click control, right of
|
|
256
|
+
* the ⋮ overflow trigger when one is present.
|
|
331
257
|
*/
|
|
332
258
|
trailingActions?: FeedAction[];
|
|
259
|
+
/**
|
|
260
|
+
* Render the feed's action UI (the top-right action bar: camera stepper,
|
|
261
|
+
* custom actions, quality / fullscreen / PiP). Default true. Set false to
|
|
262
|
+
* suppress the whole bar (e.g. a small tile where hover controls add
|
|
263
|
+
* nothing). Does not affect display-size reporting.
|
|
264
|
+
*/
|
|
265
|
+
showActions?: boolean;
|
|
333
266
|
/**
|
|
334
267
|
* Override how the displayed video stream is sourced for the resolved
|
|
335
268
|
* flightId. Omit (the default) to use the built-in `useKerbcastStream` —
|
|
@@ -357,6 +290,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
357
290
|
{
|
|
358
291
|
flightId: requestedFlightId,
|
|
359
292
|
onSelectCamera,
|
|
293
|
+
cameraFilter,
|
|
360
294
|
onDisplayedCameraChange,
|
|
361
295
|
showDebugInfo = false,
|
|
362
296
|
showStatic,
|
|
@@ -367,14 +301,29 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
367
301
|
enableFullscreen = false,
|
|
368
302
|
enablePictureInPicture = false,
|
|
369
303
|
enableQualityControl = false,
|
|
304
|
+
enableTracking = false,
|
|
305
|
+
enableRecording = false,
|
|
306
|
+
recordFullResolution = false,
|
|
370
307
|
actions,
|
|
371
308
|
trailingActions,
|
|
309
|
+
showActions = true,
|
|
372
310
|
useStream,
|
|
373
311
|
},
|
|
374
312
|
ref,
|
|
375
313
|
) {
|
|
376
314
|
const client = useKerbcastClient();
|
|
377
|
-
|
|
315
|
+
// Always called (rules of hooks); `enableRecording` only gates whether the
|
|
316
|
+
// REC entry/badge render, not whether the store is subscribed.
|
|
317
|
+
const recordings = useRecordings();
|
|
318
|
+
// The selectable set: every live camera, optionally narrowed by the host's
|
|
319
|
+
// cameraFilter (e.g. a part-grid tile offering only part cams). Drives the
|
|
320
|
+
// picker menu, the stepper, and the auto-latch fallback so none can land on
|
|
321
|
+
// a filtered-out camera.
|
|
322
|
+
const allCameras = useKerbcastCameras();
|
|
323
|
+
const cameras = useMemo(
|
|
324
|
+
() => (cameraFilter ? allCameras.filter(cameraFilter) : allCameras),
|
|
325
|
+
[allCameras, cameraFilter],
|
|
326
|
+
);
|
|
378
327
|
const inFlightFromClient = useKerbcastInFlight();
|
|
379
328
|
const inFlight = inFlightProp ?? inFlightFromClient;
|
|
380
329
|
const outOfFlight = inFlight === false;
|
|
@@ -549,6 +498,14 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
549
498
|
const isDestroyed = camera ? isCameraDestroyed(camera) : false;
|
|
550
499
|
const showPan = camera?.supportsPan && !isDestroyed;
|
|
551
500
|
const showZoom = camera?.supportsZoom && !isDestroyed;
|
|
501
|
+
|
|
502
|
+
// Auto-track state is server-authoritative: driven purely by the published
|
|
503
|
+
// trackMode (absent -> none), never local click. While tracking, the aim
|
|
504
|
+
// loop owns the gimbal + FoV, so the manual pan/zoom controls are disabled
|
|
505
|
+
// (they jitter against the track and are no-ops in practice).
|
|
506
|
+
const trackMode = camera?.trackMode ?? TrackMode.None;
|
|
507
|
+
const tracking = trackMode !== TrackMode.None;
|
|
508
|
+
const manualDisabled = tracking;
|
|
552
509
|
const supportsPitch =
|
|
553
510
|
!!camera && camera.panPitchMax - camera.panPitchMin > 0;
|
|
554
511
|
|
|
@@ -576,7 +533,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
576
533
|
);
|
|
577
534
|
|
|
578
535
|
// -------------------------------------------------------------------------
|
|
579
|
-
// PanZoomController
|
|
536
|
+
// PanZoomController: one per displayed camera
|
|
580
537
|
// -------------------------------------------------------------------------
|
|
581
538
|
const controllerRef = useRef<PanZoomController | null>(null);
|
|
582
539
|
const [sliderFov, setSliderFov] = useState(60);
|
|
@@ -593,8 +550,8 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
593
550
|
controllerRef.current = ctrl;
|
|
594
551
|
|
|
595
552
|
const unsubSlider = ctrl.onSliderFov(setSliderFov);
|
|
596
|
-
// Seed the slider from the controller's initial (0)
|
|
597
|
-
// immediately by syncFromState in the camera-state effect below.
|
|
553
|
+
// Seed the slider from the controller's initial (0). It will be
|
|
554
|
+
// overwritten immediately by syncFromState in the camera-state effect below.
|
|
598
555
|
setSliderFov(ctrl.sliderFov);
|
|
599
556
|
|
|
600
557
|
return () => {
|
|
@@ -695,35 +652,12 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
695
652
|
);
|
|
696
653
|
|
|
697
654
|
// -------------------------------------------------------------------------
|
|
698
|
-
//
|
|
655
|
+
// Display-size reporting (auto-resolution). The feed self-measures its
|
|
656
|
+
// rendered box and reports it per-consumer; the sidecar maxes across
|
|
657
|
+
// consumers. Reports the real w x h (part cams are not square). Disabled by
|
|
658
|
+
// `renderSize="none"`. See useReportDisplaySize for debounce / bucketing.
|
|
699
659
|
// -------------------------------------------------------------------------
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
useEffect(() => {
|
|
703
|
-
if (renderSize !== "auto" || flightId === null) return;
|
|
704
|
-
const el = wrapRef.current;
|
|
705
|
-
if (!el) return;
|
|
706
|
-
const cam = client.camera(flightId);
|
|
707
|
-
|
|
708
|
-
const observer = new ResizeObserver((entries) => {
|
|
709
|
-
const entry = entries[0];
|
|
710
|
-
if (!entry) return;
|
|
711
|
-
const { width } = entry.contentRect;
|
|
712
|
-
if (resizeTimerRef.current !== null) clearTimeout(resizeTimerRef.current);
|
|
713
|
-
resizeTimerRef.current = setTimeout(() => {
|
|
714
|
-
const w = toEvenPx(width);
|
|
715
|
-
const h = toEvenPx((width * 9) / 16);
|
|
716
|
-
void cam.setRenderSize(w, h);
|
|
717
|
-
}, 500);
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
observer.observe(el);
|
|
721
|
-
|
|
722
|
-
return () => {
|
|
723
|
-
observer.disconnect();
|
|
724
|
-
if (resizeTimerRef.current !== null) clearTimeout(resizeTimerRef.current);
|
|
725
|
-
};
|
|
726
|
-
}, [client, flightId, renderSize]);
|
|
660
|
+
useReportDisplaySize(flightId, wrapRef, { enabled: renderSize === "auto" });
|
|
727
661
|
|
|
728
662
|
// -------------------------------------------------------------------------
|
|
729
663
|
// UI state
|
|
@@ -776,6 +710,87 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
776
710
|
[client, flightId, closeQualityMenu],
|
|
777
711
|
);
|
|
778
712
|
|
|
713
|
+
// -------------------------------------------------------------------------
|
|
714
|
+
// Auto-track control (opt-in, pan+zoom cameras only). A crosshair button +
|
|
715
|
+
// menu with the two modes. Sends the intent; the highlight follows the
|
|
716
|
+
// server-published trackMode (above), so two browsers agree.
|
|
717
|
+
// -------------------------------------------------------------------------
|
|
718
|
+
const trackingAvailable =
|
|
719
|
+
enableTracking &&
|
|
720
|
+
flightId !== null &&
|
|
721
|
+
camera !== null &&
|
|
722
|
+
camera.supportsPan === true &&
|
|
723
|
+
camera.supportsZoom === true &&
|
|
724
|
+
!isDestroyed;
|
|
725
|
+
const trackingMenuId = useId();
|
|
726
|
+
const trackingMenu = usePortalMenu({
|
|
727
|
+
maxWidth: QUALITY_MENU_MAX_WIDTH,
|
|
728
|
+
maxHeight: QUALITY_MENU_MAX_HEIGHT,
|
|
729
|
+
align: "end",
|
|
730
|
+
});
|
|
731
|
+
const closeTrackingMenu = trackingMenu.close;
|
|
732
|
+
|
|
733
|
+
const selectTrack = useCallback(
|
|
734
|
+
(mode: TrackMode) => {
|
|
735
|
+
if (flightId === null) return;
|
|
736
|
+
// Tri-state: clicking the already-active mode hands aiming back (none).
|
|
737
|
+
// Decision reads the server-confirmed trackMode, not a local toggle.
|
|
738
|
+
const next = trackMode === mode ? TrackMode.None : mode;
|
|
739
|
+
void client.setTrackTarget(flightId, next);
|
|
740
|
+
closeTrackingMenu();
|
|
741
|
+
},
|
|
742
|
+
[client, flightId, trackMode, closeTrackingMenu],
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
// -------------------------------------------------------------------------
|
|
746
|
+
// REC control (opt-in). A stateful toggle that starts/stops a client-side
|
|
747
|
+
// recording of this feed via the recordings store; state is read from the
|
|
748
|
+
// store (isRecording), never held locally, so it stays correct if the
|
|
749
|
+
// recording was started elsewhere (e.g. a REC+ grouped recording).
|
|
750
|
+
// -------------------------------------------------------------------------
|
|
751
|
+
const recordingActive =
|
|
752
|
+
enableRecording && flightId !== null && recordings.isRecording(flightId);
|
|
753
|
+
const activeRecording = recordingActive
|
|
754
|
+
? recordings.active.find((a) => a.flightId === flightId)
|
|
755
|
+
: undefined;
|
|
756
|
+
/* True while a single forced recording is armed (force sent, waiting for
|
|
757
|
+
the feed to reach full resolution) but the recorder has not started
|
|
758
|
+
yet (see ActiveRecordingInfo.arming). Drives the ARMING pill below in
|
|
759
|
+
place of the REC badge. */
|
|
760
|
+
const armingActive = activeRecording?.arming === true;
|
|
761
|
+
|
|
762
|
+
// Ticks once a second while recording so the elapsed timer stays live;
|
|
763
|
+
// the timer itself is derived from activeRecording.startedAt each render.
|
|
764
|
+
const [, tickRecordingTimer] = useReducer((c: number) => c + 1, 0);
|
|
765
|
+
useEffect(() => {
|
|
766
|
+
if (!recordingActive) return;
|
|
767
|
+
const interval = setInterval(tickRecordingTimer, 1000);
|
|
768
|
+
return () => clearInterval(interval);
|
|
769
|
+
}, [recordingActive]);
|
|
770
|
+
|
|
771
|
+
const recordingElapsedMs = activeRecording ? nowMs() - activeRecording.startedAt : 0;
|
|
772
|
+
|
|
773
|
+
const toggleRecording = useCallback(() => {
|
|
774
|
+
if (flightId === null) return;
|
|
775
|
+
if (recordingActive) {
|
|
776
|
+
const active = recordings.active.find((a) => a.flightId === flightId);
|
|
777
|
+
if (active) {
|
|
778
|
+
void recordings.stop(active.recordingId).catch(() => {
|
|
779
|
+
/* A rejected stop shouldn't leave the REC toggle wedged; the
|
|
780
|
+
store's own state (active/recordings) is the source of truth
|
|
781
|
+
for whether it's still "recording", not this call's outcome. */
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
try {
|
|
787
|
+
recordings.start(flightId, { forceFullResolution: recordFullResolution });
|
|
788
|
+
} catch {
|
|
789
|
+
/* no live track yet, or a race with a recording already in progress
|
|
790
|
+
elsewhere on this feed; the operator can retry once live. */
|
|
791
|
+
}
|
|
792
|
+
}, [flightId, recordingActive, recordings, recordFullResolution]);
|
|
793
|
+
|
|
779
794
|
const topOverlay = (
|
|
780
795
|
<TopOverlay>
|
|
781
796
|
<TitleRow>
|
|
@@ -787,7 +802,9 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
787
802
|
aria-haspopup="menu"
|
|
788
803
|
aria-expanded={cameraMenu.open}
|
|
789
804
|
aria-controls={menuId}
|
|
790
|
-
|
|
805
|
+
disabled={recordingActive}
|
|
806
|
+
title={recordingActive ? "Stop recording to change camera" : undefined}
|
|
807
|
+
onClick={recordingActive ? undefined : cameraMenu.toggle}
|
|
791
808
|
>
|
|
792
809
|
<TitleButton__Text>{title}</TitleButton__Text>
|
|
793
810
|
<ChevronDownIcon aria-hidden="true" />
|
|
@@ -842,97 +859,205 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
842
859
|
</TopOverlay>
|
|
843
860
|
);
|
|
844
861
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
(actions
|
|
864
|
-
(
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
862
|
+
/*
|
|
863
|
+
* Built-in action-bar entries, in the bar's natural (fallback) order:
|
|
864
|
+
* consumer actions, quality, tracking, PiP, fullscreen, trailing actions.
|
|
865
|
+
* FeedActionBar partitions these into primary/overflow (see its own
|
|
866
|
+
* doc-comment); tracking is the only built-in marked `stateful` today,
|
|
867
|
+
* per #6's "stateful toggles stay primary" rule (REC joins it the same
|
|
868
|
+
* way).
|
|
869
|
+
*/
|
|
870
|
+
const trackingLabel = tracking
|
|
871
|
+
? trackMode === TrackMode.Target
|
|
872
|
+
? "Auto-tracking target"
|
|
873
|
+
: "Auto-tracking active vessel"
|
|
874
|
+
: "Auto-track a vessel";
|
|
875
|
+
const pipLabel = isPip ? "Exit picture in picture" : "Picture in picture";
|
|
876
|
+
const fullscreenLabel = isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
|
|
877
|
+
const recordingLabel = recordingActive ? "Stop recording" : "Start recording";
|
|
878
|
+
|
|
879
|
+
const entries: FeedActionBarEntry[] = [
|
|
880
|
+
...(actions?.map(feedActionToEntry) ?? []),
|
|
881
|
+
...(qualityAvailable
|
|
882
|
+
? [
|
|
883
|
+
{
|
|
884
|
+
id: "quality",
|
|
885
|
+
label: "Quality",
|
|
886
|
+
stateful: false,
|
|
887
|
+
render: () => (
|
|
888
|
+
<OverlayIconButton
|
|
889
|
+
ref={qualityMenu.triggerRef}
|
|
890
|
+
type="button"
|
|
891
|
+
aria-label="Quality"
|
|
892
|
+
aria-haspopup="menu"
|
|
893
|
+
aria-expanded={qualityMenu.open}
|
|
894
|
+
aria-controls={qualityMenuId}
|
|
895
|
+
title={
|
|
896
|
+
qualityThrottled
|
|
897
|
+
? "Quality (throttled by adaptive performance)"
|
|
898
|
+
: "Quality"
|
|
899
|
+
}
|
|
900
|
+
$active={qualityMenu.open}
|
|
901
|
+
onClick={qualityMenu.toggle}
|
|
902
|
+
>
|
|
903
|
+
<QualityIcon />
|
|
904
|
+
{qualityThrottled && <ThrottledDot aria-hidden="true" />}
|
|
905
|
+
</OverlayIconButton>
|
|
906
|
+
),
|
|
907
|
+
} satisfies FeedActionBarEntry,
|
|
908
|
+
]
|
|
909
|
+
: []),
|
|
910
|
+
...(enableRecording && flightId !== null
|
|
911
|
+
? [
|
|
912
|
+
{
|
|
913
|
+
id: "record",
|
|
914
|
+
label: recordingLabel,
|
|
915
|
+
stateful: true,
|
|
916
|
+
render: () => (
|
|
917
|
+
<RecordActionButton
|
|
918
|
+
type="button"
|
|
919
|
+
aria-label={recordingLabel}
|
|
920
|
+
aria-pressed={recordingActive}
|
|
921
|
+
title={recordingLabel}
|
|
922
|
+
$active={recordingActive}
|
|
923
|
+
onClick={toggleRecording}
|
|
924
|
+
>
|
|
925
|
+
<RecordIcon />
|
|
926
|
+
</RecordActionButton>
|
|
927
|
+
),
|
|
928
|
+
} satisfies FeedActionBarEntry,
|
|
929
|
+
]
|
|
930
|
+
: []),
|
|
931
|
+
...(trackingAvailable
|
|
932
|
+
? [
|
|
933
|
+
{
|
|
934
|
+
id: "tracking",
|
|
935
|
+
label: trackingLabel,
|
|
936
|
+
stateful: true,
|
|
937
|
+
render: () => (
|
|
938
|
+
<OverlayIconButton
|
|
939
|
+
ref={trackingMenu.triggerRef}
|
|
940
|
+
type="button"
|
|
941
|
+
aria-label="Auto-track"
|
|
942
|
+
aria-haspopup="menu"
|
|
943
|
+
aria-expanded={trackingMenu.open}
|
|
944
|
+
aria-controls={trackingMenuId}
|
|
945
|
+
aria-pressed={tracking}
|
|
946
|
+
title={trackingLabel}
|
|
947
|
+
$active={tracking || trackingMenu.open}
|
|
948
|
+
onClick={trackingMenu.toggle}
|
|
949
|
+
>
|
|
950
|
+
<CrosshairIcon />
|
|
951
|
+
</OverlayIconButton>
|
|
952
|
+
),
|
|
953
|
+
} satisfies FeedActionBarEntry,
|
|
954
|
+
]
|
|
955
|
+
: []),
|
|
956
|
+
...(flightId !== null && pipAvailable
|
|
957
|
+
? [
|
|
958
|
+
{
|
|
959
|
+
id: "pip",
|
|
960
|
+
label: pipLabel,
|
|
961
|
+
stateful: false,
|
|
962
|
+
render: () => (
|
|
963
|
+
<OverlayIconButton
|
|
964
|
+
type="button"
|
|
965
|
+
aria-label={pipLabel}
|
|
966
|
+
aria-pressed={isPip}
|
|
967
|
+
title={pipLabel}
|
|
968
|
+
$active={isPip}
|
|
969
|
+
onClick={togglePip}
|
|
970
|
+
>
|
|
971
|
+
<PictureInPictureIcon />
|
|
972
|
+
</OverlayIconButton>
|
|
973
|
+
),
|
|
974
|
+
} satisfies FeedActionBarEntry,
|
|
975
|
+
]
|
|
976
|
+
: []),
|
|
977
|
+
...(flightId !== null && fullscreenAvailable
|
|
978
|
+
? [
|
|
979
|
+
{
|
|
980
|
+
id: "fullscreen",
|
|
981
|
+
label: fullscreenLabel,
|
|
982
|
+
stateful: false,
|
|
983
|
+
render: () => (
|
|
984
|
+
<OverlayIconButton
|
|
985
|
+
type="button"
|
|
986
|
+
aria-label={fullscreenLabel}
|
|
987
|
+
aria-pressed={isFullscreen}
|
|
988
|
+
title={fullscreenLabel}
|
|
989
|
+
$active={isFullscreen}
|
|
990
|
+
onClick={toggleFullscreen}
|
|
991
|
+
>
|
|
992
|
+
{isFullscreen ? <FullscreenExitIcon /> : <FullscreenEnterIcon />}
|
|
993
|
+
</OverlayIconButton>
|
|
994
|
+
),
|
|
995
|
+
} satisfies FeedActionBarEntry,
|
|
996
|
+
]
|
|
997
|
+
: []),
|
|
998
|
+
/* trailingActions is always the pinned-trailing (close/remove) slot,
|
|
999
|
+
regardless of what the consumer's FeedAction sets. See its own
|
|
1000
|
+
doc-comment on CameraFeedProps. While a recording is active, closing
|
|
1001
|
+
the feed would lose it, so the control stays visible but disabled
|
|
1002
|
+
and inert rather than disappearing. */
|
|
1003
|
+
...(trailingActions?.map((a) => {
|
|
1004
|
+
const entry = feedActionToEntry(a);
|
|
1005
|
+
if (!recordingActive) return { ...entry, pinnedTrailing: true };
|
|
1006
|
+
return {
|
|
1007
|
+
...entry,
|
|
1008
|
+
pinnedTrailing: true,
|
|
1009
|
+
render: () => (
|
|
922
1010
|
<OverlayIconButton
|
|
1011
|
+
key={a.id}
|
|
923
1012
|
type="button"
|
|
924
|
-
aria-label={
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
$active={
|
|
928
|
-
onClick={toggleFullscreen}
|
|
1013
|
+
aria-label={a.label}
|
|
1014
|
+
title="Stop recording to close this feed"
|
|
1015
|
+
disabled
|
|
1016
|
+
$active={a.active ?? false}
|
|
929
1017
|
>
|
|
930
|
-
{
|
|
1018
|
+
{a.icon}
|
|
931
1019
|
</OverlayIconButton>
|
|
932
|
-
)
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1020
|
+
),
|
|
1021
|
+
};
|
|
1022
|
+
}) ?? []),
|
|
1023
|
+
];
|
|
1024
|
+
|
|
1025
|
+
const builtInActions =
|
|
1026
|
+
flightId !== null &&
|
|
1027
|
+
(pipAvailable ||
|
|
1028
|
+
fullscreenAvailable ||
|
|
1029
|
+
qualityAvailable ||
|
|
1030
|
+
trackingAvailable ||
|
|
1031
|
+
enableRecording);
|
|
1032
|
+
const hasActionBar =
|
|
1033
|
+
showActions &&
|
|
1034
|
+
(hasCameras ||
|
|
1035
|
+
(actions && actions.length > 0) ||
|
|
1036
|
+
(trailingActions && trailingActions.length > 0) ||
|
|
1037
|
+
builtInActions);
|
|
1038
|
+
const stepButtons = hasCameras ? (
|
|
1039
|
+
<StepButtons>
|
|
1040
|
+
<OverlayIconButton
|
|
1041
|
+
type="button"
|
|
1042
|
+
aria-label="Previous camera"
|
|
1043
|
+
disabled={!canStep}
|
|
1044
|
+
onClick={() => stepCamera(-1)}
|
|
1045
|
+
>
|
|
1046
|
+
‹
|
|
1047
|
+
</OverlayIconButton>
|
|
1048
|
+
<OverlayIconButton
|
|
1049
|
+
type="button"
|
|
1050
|
+
aria-label="Next camera"
|
|
1051
|
+
disabled={!canStep}
|
|
1052
|
+
onClick={() => stepCamera(1)}
|
|
1053
|
+
>
|
|
1054
|
+
›
|
|
1055
|
+
</OverlayIconButton>
|
|
1056
|
+
</StepButtons>
|
|
1057
|
+
) : null;
|
|
1058
|
+
const actionBar = hasActionBar ? (
|
|
1059
|
+
<FeedActionBar leading={stepButtons} entries={entries} />
|
|
1060
|
+
) : null;
|
|
936
1061
|
|
|
937
1062
|
return (
|
|
938
1063
|
<Stage ref={wrapRef} $pinned={chromePinned}>
|
|
@@ -995,6 +1120,39 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
995
1120
|
</QualityMenu>,
|
|
996
1121
|
document.body,
|
|
997
1122
|
)}
|
|
1123
|
+
{trackingAvailable &&
|
|
1124
|
+
trackingMenu.open &&
|
|
1125
|
+
trackingMenu.position &&
|
|
1126
|
+
camera &&
|
|
1127
|
+
createPortal(
|
|
1128
|
+
<QualityMenu
|
|
1129
|
+
ref={trackingMenu.menuRef}
|
|
1130
|
+
id={trackingMenuId}
|
|
1131
|
+
role="menu"
|
|
1132
|
+
aria-label="Auto-track"
|
|
1133
|
+
style={trackingMenu.position}
|
|
1134
|
+
>
|
|
1135
|
+
<CameraMenuItem
|
|
1136
|
+
type="button"
|
|
1137
|
+
role="menuitemradio"
|
|
1138
|
+
aria-checked={trackMode === TrackMode.ActiveVessel}
|
|
1139
|
+
$selected={trackMode === TrackMode.ActiveVessel}
|
|
1140
|
+
onClick={() => selectTrack(TrackMode.ActiveVessel)}
|
|
1141
|
+
>
|
|
1142
|
+
Track active vessel
|
|
1143
|
+
</CameraMenuItem>
|
|
1144
|
+
<CameraMenuItem
|
|
1145
|
+
type="button"
|
|
1146
|
+
role="menuitemradio"
|
|
1147
|
+
aria-checked={trackMode === TrackMode.Target}
|
|
1148
|
+
$selected={trackMode === TrackMode.Target}
|
|
1149
|
+
onClick={() => selectTrack(TrackMode.Target)}
|
|
1150
|
+
>
|
|
1151
|
+
Track target
|
|
1152
|
+
</CameraMenuItem>
|
|
1153
|
+
</QualityMenu>,
|
|
1154
|
+
document.body,
|
|
1155
|
+
)}
|
|
998
1156
|
{outOfFlight ? (
|
|
999
1157
|
showStandbyIcon && (
|
|
1000
1158
|
<StandbyOverlay role="status" aria-label="Standby, no active flight">
|
|
@@ -1021,12 +1179,25 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1021
1179
|
</StaleBadge>
|
|
1022
1180
|
</>
|
|
1023
1181
|
)}
|
|
1182
|
+
{recordingActive && armingActive && (
|
|
1183
|
+
<ArmingBadge role="status" aria-label="Arming">
|
|
1184
|
+
<ArmingDot aria-hidden="true" />
|
|
1185
|
+
ARMING
|
|
1186
|
+
</ArmingBadge>
|
|
1187
|
+
)}
|
|
1188
|
+
{recordingActive && !armingActive && (
|
|
1189
|
+
<RecBadge role="status" aria-label="Recording">
|
|
1190
|
+
<RecDot aria-hidden="true" />
|
|
1191
|
+
REC {formatElapsed(recordingElapsedMs)}
|
|
1192
|
+
</RecBadge>
|
|
1193
|
+
)}
|
|
1024
1194
|
{showZoom && (
|
|
1025
|
-
<ZoomControlsWrap>
|
|
1195
|
+
<ZoomControlsWrap $disabled={manualDisabled} aria-hidden={manualDisabled}>
|
|
1026
1196
|
<ZoomButton
|
|
1027
1197
|
type="button"
|
|
1028
1198
|
aria-label="Zoom in"
|
|
1029
1199
|
$pos="top"
|
|
1200
|
+
disabled={manualDisabled}
|
|
1030
1201
|
onPointerDown={() =>
|
|
1031
1202
|
controllerRef.current?.setZoomRate(1)
|
|
1032
1203
|
}
|
|
@@ -1046,6 +1217,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1046
1217
|
max={camera.fovMax}
|
|
1047
1218
|
step={0.5}
|
|
1048
1219
|
value={sliderFov}
|
|
1220
|
+
disabled={manualDisabled}
|
|
1049
1221
|
onChange={(e) => {
|
|
1050
1222
|
const v = Number(e.target.value);
|
|
1051
1223
|
controllerRef.current?.fovSliderInput(v);
|
|
@@ -1064,6 +1236,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1064
1236
|
type="button"
|
|
1065
1237
|
aria-label="Zoom out"
|
|
1066
1238
|
$pos="bottom"
|
|
1239
|
+
disabled={manualDisabled}
|
|
1067
1240
|
onPointerDown={() => controllerRef.current?.setZoomRate(-1)}
|
|
1068
1241
|
onPointerUp={() => controllerRef.current?.setZoomRate(0)}
|
|
1069
1242
|
onPointerLeave={() => controllerRef.current?.setZoomRate(0)}
|
|
@@ -1077,12 +1250,17 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1077
1250
|
</ZoomControlsWrap>
|
|
1078
1251
|
)}
|
|
1079
1252
|
{showPan && (
|
|
1080
|
-
<PanControl
|
|
1253
|
+
<PanControl
|
|
1254
|
+
role="group"
|
|
1255
|
+
aria-label="Pan camera"
|
|
1256
|
+
$disabled={manualDisabled}
|
|
1257
|
+
aria-hidden={manualDisabled}
|
|
1258
|
+
>
|
|
1081
1259
|
<PanArrow
|
|
1082
1260
|
type="button"
|
|
1083
1261
|
$dir="up"
|
|
1084
1262
|
aria-label="Pan up"
|
|
1085
|
-
disabled={!supportsPitch}
|
|
1263
|
+
disabled={manualDisabled || !supportsPitch}
|
|
1086
1264
|
onClick={() => controllerRef.current?.nudgePan(0, 1)}
|
|
1087
1265
|
>
|
|
1088
1266
|
▲
|
|
@@ -1091,7 +1269,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1091
1269
|
type="button"
|
|
1092
1270
|
$dir="down"
|
|
1093
1271
|
aria-label="Pan down"
|
|
1094
|
-
disabled={!supportsPitch}
|
|
1272
|
+
disabled={manualDisabled || !supportsPitch}
|
|
1095
1273
|
onClick={() => controllerRef.current?.nudgePan(0, -1)}
|
|
1096
1274
|
>
|
|
1097
1275
|
▼
|
|
@@ -1100,6 +1278,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1100
1278
|
type="button"
|
|
1101
1279
|
$dir="left"
|
|
1102
1280
|
aria-label="Pan left"
|
|
1281
|
+
disabled={manualDisabled}
|
|
1103
1282
|
onClick={() => controllerRef.current?.nudgePan(-1, 0)}
|
|
1104
1283
|
>
|
|
1105
1284
|
◀
|
|
@@ -1108,6 +1287,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1108
1287
|
type="button"
|
|
1109
1288
|
$dir="right"
|
|
1110
1289
|
aria-label="Pan right"
|
|
1290
|
+
disabled={manualDisabled}
|
|
1111
1291
|
onClick={() => controllerRef.current?.nudgePan(1, 0)}
|
|
1112
1292
|
>
|
|
1113
1293
|
▶
|
|
@@ -1115,10 +1295,10 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1115
1295
|
<PanBall
|
|
1116
1296
|
aria-hidden="true"
|
|
1117
1297
|
title="Drag to pan"
|
|
1118
|
-
onPointerDown={handleBallDown}
|
|
1119
|
-
onPointerMove={handleBallMove}
|
|
1120
|
-
onPointerUp={handleBallUp}
|
|
1121
|
-
onPointerCancel={handleBallUp}
|
|
1298
|
+
onPointerDown={manualDisabled ? undefined : handleBallDown}
|
|
1299
|
+
onPointerMove={manualDisabled ? undefined : handleBallMove}
|
|
1300
|
+
onPointerUp={manualDisabled ? undefined : handleBallUp}
|
|
1301
|
+
onPointerCancel={manualDisabled ? undefined : handleBallUp}
|
|
1122
1302
|
style={{
|
|
1123
1303
|
transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
|
|
1124
1304
|
}}
|
|
@@ -1211,6 +1391,16 @@ function QualityIcon() {
|
|
|
1211
1391
|
);
|
|
1212
1392
|
}
|
|
1213
1393
|
|
|
1394
|
+
/* Auto-track: a crosshair / reticle. */
|
|
1395
|
+
function CrosshairIcon() {
|
|
1396
|
+
return (
|
|
1397
|
+
<svg {...iconProps}>
|
|
1398
|
+
<circle cx="8" cy="8" r="4.5" />
|
|
1399
|
+
<path d="M8 1v2.5M8 12.5V15M1 8h2.5M12.5 8H15" />
|
|
1400
|
+
</svg>
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1214
1404
|
/* Stale badge: fading signal bars (tallest dimmed). */
|
|
1215
1405
|
function StaleIcon() {
|
|
1216
1406
|
return (
|
|
@@ -1238,11 +1428,20 @@ function PictureInPictureIcon() {
|
|
|
1238
1428
|
);
|
|
1239
1429
|
}
|
|
1240
1430
|
|
|
1431
|
+
/* REC: a plain filled dot, coloured by RecordActionButton's own $active state. */
|
|
1432
|
+
function RecordIcon() {
|
|
1433
|
+
return (
|
|
1434
|
+
<svg viewBox="0 0 16 16" width={14} height={14} fill="currentColor" stroke="none" aria-hidden="true">
|
|
1435
|
+
<circle cx="8" cy="8" r="5" />
|
|
1436
|
+
</svg>
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1241
1440
|
// ---------------------------------------------------------------------------
|
|
1242
1441
|
// Styled components
|
|
1243
1442
|
// ---------------------------------------------------------------------------
|
|
1244
1443
|
|
|
1245
|
-
const PanControl = styled.div
|
|
1444
|
+
const PanControl = styled.div<{ $disabled?: boolean }>`
|
|
1246
1445
|
position: absolute;
|
|
1247
1446
|
bottom: 10px;
|
|
1248
1447
|
right: 10px;
|
|
@@ -1252,6 +1451,9 @@ const PanControl = styled.div`
|
|
|
1252
1451
|
transition: opacity 0.15s;
|
|
1253
1452
|
touch-action: none;
|
|
1254
1453
|
|
|
1454
|
+
/* Greyed + inert while auto-tracking owns the gimbal. */
|
|
1455
|
+
${(p) => p.$disabled && "pointer-events: none; filter: grayscale(1) opacity(0.4);"}
|
|
1456
|
+
|
|
1255
1457
|
@media (prefers-reduced-motion: reduce) {
|
|
1256
1458
|
transition: none;
|
|
1257
1459
|
}
|
|
@@ -1322,7 +1524,7 @@ const PanBall = styled.div`
|
|
|
1322
1524
|
}
|
|
1323
1525
|
`;
|
|
1324
1526
|
|
|
1325
|
-
const ZoomControlsWrap = styled.div
|
|
1527
|
+
const ZoomControlsWrap = styled.div<{ $disabled?: boolean }>`
|
|
1326
1528
|
position: absolute;
|
|
1327
1529
|
bottom: 8px;
|
|
1328
1530
|
left: 8px;
|
|
@@ -1335,6 +1537,9 @@ const ZoomControlsWrap = styled.div`
|
|
|
1335
1537
|
opacity: 0;
|
|
1336
1538
|
transition: opacity 0.15s;
|
|
1337
1539
|
|
|
1540
|
+
/* Greyed + inert while auto-tracking owns the FoV. */
|
|
1541
|
+
${(p) => p.$disabled && "pointer-events: none; filter: grayscale(1) opacity(0.4);"}
|
|
1542
|
+
|
|
1338
1543
|
@media (prefers-reduced-motion: reduce) {
|
|
1339
1544
|
transition: none;
|
|
1340
1545
|
}
|
|
@@ -1560,21 +1765,114 @@ const ThrottledDot = styled.span`
|
|
|
1560
1765
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6);
|
|
1561
1766
|
`;
|
|
1562
1767
|
|
|
1563
|
-
/*
|
|
1564
|
-
|
|
1768
|
+
/* REC action-bar button: same shape as OverlayIconButton, its own red
|
|
1769
|
+
$active colour (a coming-recording toggle should never read as the
|
|
1770
|
+
green "on" of tracking/quality) via its own token so a consumer can
|
|
1771
|
+
recolour it without touching --kerbcast-action-active. */
|
|
1772
|
+
const RecordActionButton = styled(OverlayIconButton)<{ $active?: boolean }>`
|
|
1773
|
+
background: ${(p) =>
|
|
1774
|
+
p.$active ? "var(--kerbcast-rec-active, #ff3b30)" : "rgba(0, 0, 0, 0.5)"};
|
|
1775
|
+
border-color: ${(p) =>
|
|
1776
|
+
p.$active ? "var(--kerbcast-rec-active, #ff3b30)" : "rgba(255, 255, 255, 0.3)"};
|
|
1777
|
+
color: #fff;
|
|
1778
|
+
|
|
1779
|
+
@media (hover: hover) {
|
|
1780
|
+
&:hover {
|
|
1781
|
+
background: ${(p) =>
|
|
1782
|
+
p.$active ? "var(--kerbcast-rec-active, #ff3b30)" : "rgba(0, 0, 0, 0.7)"};
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
`;
|
|
1786
|
+
|
|
1787
|
+
/* Persistent recording badge: red dot + elapsed timer, always visible while
|
|
1788
|
+
recording (not hover chrome), same convention as StaleBadge. */
|
|
1789
|
+
const RecBadge = styled.div`
|
|
1565
1790
|
position: absolute;
|
|
1566
|
-
top
|
|
1567
|
-
|
|
1568
|
-
|
|
1791
|
+
/* Sits just under the hover-gated title/source dropdown (both are top-left)
|
|
1792
|
+
so an active recording stays visible without displacing that control. */
|
|
1793
|
+
top: 28px;
|
|
1794
|
+
left: 8px;
|
|
1795
|
+
z-index: 2;
|
|
1569
1796
|
display: flex;
|
|
1570
1797
|
align-items: center;
|
|
1571
|
-
gap:
|
|
1572
|
-
|
|
1798
|
+
gap: 5px;
|
|
1799
|
+
padding: 2px 6px;
|
|
1800
|
+
background: rgba(0, 0, 0, 0.6);
|
|
1801
|
+
border: 1px solid var(--kerbcast-rec-active, #ff3b30);
|
|
1802
|
+
border-radius: 3px;
|
|
1803
|
+
color: #fff;
|
|
1804
|
+
font-size: 10px;
|
|
1805
|
+
font-weight: 600;
|
|
1806
|
+
letter-spacing: 0.12em;
|
|
1807
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
1573
1808
|
pointer-events: none;
|
|
1574
|
-
|
|
1809
|
+
`;
|
|
1575
1810
|
|
|
1576
|
-
|
|
1577
|
-
|
|
1811
|
+
const RecDot = styled.span`
|
|
1812
|
+
width: 7px;
|
|
1813
|
+
height: 7px;
|
|
1814
|
+
border-radius: 50%;
|
|
1815
|
+
background: var(--kerbcast-rec-active, #ff3b30);
|
|
1816
|
+
flex-shrink: 0;
|
|
1817
|
+
|
|
1818
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
1819
|
+
animation: rec-dot-pulse 1.6s ease-in-out infinite;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
@keyframes rec-dot-pulse {
|
|
1823
|
+
0%,
|
|
1824
|
+
100% {
|
|
1825
|
+
opacity: 1;
|
|
1826
|
+
}
|
|
1827
|
+
50% {
|
|
1828
|
+
opacity: 0.4;
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
`;
|
|
1832
|
+
|
|
1833
|
+
/* Arming pill: same slot as RecBadge (a single forced recording shows one or
|
|
1834
|
+
the other, never both), a muted amber so it never reads as the red
|
|
1835
|
+
"recording is live" state: the feed hasn't actually started capturing
|
|
1836
|
+
yet, it's waiting for the resolution bump. */
|
|
1837
|
+
const ArmingBadge = styled.div`
|
|
1838
|
+
position: absolute;
|
|
1839
|
+
top: 28px;
|
|
1840
|
+
left: 8px;
|
|
1841
|
+
z-index: 2;
|
|
1842
|
+
display: flex;
|
|
1843
|
+
align-items: center;
|
|
1844
|
+
gap: 5px;
|
|
1845
|
+
padding: 2px 6px;
|
|
1846
|
+
background: rgba(0, 0, 0, 0.6);
|
|
1847
|
+
border: 1px solid var(--kerbcast-arming-active, #d9a441);
|
|
1848
|
+
border-radius: 3px;
|
|
1849
|
+
color: #fff;
|
|
1850
|
+
font-size: 10px;
|
|
1851
|
+
font-weight: 600;
|
|
1852
|
+
letter-spacing: 0.12em;
|
|
1853
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
1854
|
+
pointer-events: none;
|
|
1855
|
+
`;
|
|
1856
|
+
|
|
1857
|
+
const ArmingDot = styled.span`
|
|
1858
|
+
width: 7px;
|
|
1859
|
+
height: 7px;
|
|
1860
|
+
border-radius: 50%;
|
|
1861
|
+
background: var(--kerbcast-arming-active, #d9a441);
|
|
1862
|
+
flex-shrink: 0;
|
|
1863
|
+
|
|
1864
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
1865
|
+
animation: arming-dot-pulse 0.9s ease-in-out infinite;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
@keyframes arming-dot-pulse {
|
|
1869
|
+
0%,
|
|
1870
|
+
100% {
|
|
1871
|
+
opacity: 1;
|
|
1872
|
+
}
|
|
1873
|
+
50% {
|
|
1874
|
+
opacity: 0.3;
|
|
1875
|
+
}
|
|
1578
1876
|
}
|
|
1579
1877
|
`;
|
|
1580
1878
|
|
|
@@ -1609,14 +1907,14 @@ const Stage = styled(Panel)<{ $pinned: boolean }>`
|
|
|
1609
1907
|
&:focus-within ${ZoomControlsWrap},
|
|
1610
1908
|
&:hover ${PanControl},
|
|
1611
1909
|
&:focus-within ${PanControl},
|
|
1612
|
-
&:hover ${
|
|
1613
|
-
&:focus-within ${
|
|
1910
|
+
&:hover ${ActionBarRow},
|
|
1911
|
+
&:focus-within ${ActionBarRow} {
|
|
1614
1912
|
opacity: 1;
|
|
1615
1913
|
}
|
|
1616
1914
|
&:hover ${TopOverlay},
|
|
1617
1915
|
&:focus-within ${TopOverlay},
|
|
1618
|
-
&:hover ${
|
|
1619
|
-
&:focus-within ${
|
|
1916
|
+
&:hover ${ActionBarRow},
|
|
1917
|
+
&:focus-within ${ActionBarRow} {
|
|
1620
1918
|
pointer-events: auto;
|
|
1621
1919
|
}
|
|
1622
1920
|
|
|
@@ -1627,7 +1925,7 @@ const Stage = styled(Panel)<{ $pinned: boolean }>`
|
|
|
1627
1925
|
opacity: 1;
|
|
1628
1926
|
pointer-events: auto;
|
|
1629
1927
|
}
|
|
1630
|
-
${
|
|
1928
|
+
${ActionBarRow} {
|
|
1631
1929
|
opacity: 1;
|
|
1632
1930
|
pointer-events: auto;
|
|
1633
1931
|
}
|
|
@@ -1646,52 +1944,6 @@ const Empty = styled.div`
|
|
|
1646
1944
|
text-align: center;
|
|
1647
1945
|
`;
|
|
1648
1946
|
|
|
1649
|
-
const OverlayIconButton = styled.button<{ $active?: boolean }>`
|
|
1650
|
-
position: relative;
|
|
1651
|
-
width: 24px;
|
|
1652
|
-
height: 24px;
|
|
1653
|
-
display: flex;
|
|
1654
|
-
align-items: center;
|
|
1655
|
-
justify-content: center;
|
|
1656
|
-
padding: 0;
|
|
1657
|
-
background: ${(p) =>
|
|
1658
|
-
p.$active
|
|
1659
|
-
? "var(--kerbcast-accent, #00ff88)"
|
|
1660
|
-
: "rgba(0, 0, 0, 0.5)"};
|
|
1661
|
-
border: 1px solid
|
|
1662
|
-
${(p) =>
|
|
1663
|
-
p.$active
|
|
1664
|
-
? "var(--kerbcast-accent, #00ff88)"
|
|
1665
|
-
: "rgba(255, 255, 255, 0.3)"};
|
|
1666
|
-
border-radius: 3px;
|
|
1667
|
-
color: ${(p) => (p.$active ? "#000" : "#fff")};
|
|
1668
|
-
cursor: pointer;
|
|
1669
|
-
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
|
1670
|
-
|
|
1671
|
-
@media (hover: hover) {
|
|
1672
|
-
&:hover {
|
|
1673
|
-
background: ${(p) =>
|
|
1674
|
-
p.$active
|
|
1675
|
-
? "var(--kerbcast-accent, #00ff88)"
|
|
1676
|
-
: "rgba(0, 0, 0, 0.7)"};
|
|
1677
|
-
border-color: rgba(255, 255, 255, 0.6);
|
|
1678
|
-
}
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
&:focus-visible {
|
|
1682
|
-
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
1683
|
-
outline-offset: 2px;
|
|
1684
|
-
}
|
|
1685
|
-
|
|
1686
|
-
&:disabled {
|
|
1687
|
-
opacity: 0.4;
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
@media (prefers-reduced-motion: reduce) {
|
|
1691
|
-
transition: none;
|
|
1692
|
-
}
|
|
1693
|
-
`;
|
|
1694
|
-
|
|
1695
1947
|
const StyledVideo = styled.video`
|
|
1696
1948
|
position: absolute;
|
|
1697
1949
|
inset: 0;
|