@ksp-gonogo/kerbcast-react 1.4.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 +26 -0
- package/LICENSE +369 -0
- package/README.md +26 -0
- package/dist/CameraFeed.d.ts +126 -0
- package/dist/CameraFeed.js +1110 -0
- package/dist/cameraLabels.d.ts +14 -0
- package/dist/cameraLabels.js +59 -0
- package/dist/context.d.ts +34 -0
- package/dist/context.js +70 -0
- package/dist/hooks/useKerbcamCameras.d.ts +8 -0
- package/dist/hooks/useKerbcamCameras.js +19 -0
- package/dist/hooks/useKerbcamStream.d.ts +10 -0
- package/dist/hooks/useKerbcamStream.js +35 -0
- package/dist/hooks/useKerbcastCameras.d.ts +8 -0
- package/dist/hooks/useKerbcastCameras.js +19 -0
- package/dist/hooks/useKerbcastClock.d.ts +20 -0
- package/dist/hooks/useKerbcastClock.js +26 -0
- package/dist/hooks/useKerbcastStream.d.ts +10 -0
- package/dist/hooks/useKerbcastStream.js +35 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +19 -0
- package/dist/lifecycle.d.ts +5 -0
- package/dist/lifecycle.js +9 -0
- package/package.json +67 -0
- package/src/CameraFeed.test.tsx +1608 -0
- package/src/CameraFeed.tsx +1757 -0
- package/src/cameraLabels.test.ts +82 -0
- package/src/cameraLabels.ts +68 -0
- package/src/context.tsx +129 -0
- package/src/hooks/useKerbcastCameras.ts +24 -0
- package/src/hooks/useKerbcastClock.test.tsx +73 -0
- package/src/hooks/useKerbcastClock.ts +33 -0
- package/src/hooks/useKerbcastStream.test.tsx +119 -0
- package/src/hooks/useKerbcastStream.ts +38 -0
- package/src/index.test.ts +10 -0
- package/src/index.ts +38 -0
- package/src/lifecycle.ts +14 -0
- package/src/test/setup.ts +3 -0
|
@@ -0,0 +1,1757 @@
|
|
|
1
|
+
import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
2
|
+
import { PanZoomController, QualityPreset } from "@ksp-gonogo/kerbcast";
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useId,
|
|
8
|
+
useImperativeHandle,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from "react";
|
|
13
|
+
import { createPortal } from "react-dom";
|
|
14
|
+
import styled, { css } from "styled-components";
|
|
15
|
+
import { buildCameraLabeler } from "./cameraLabels";
|
|
16
|
+
import { KerbcastProvider, useKerbcastClient } from "./context";
|
|
17
|
+
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
18
|
+
import { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
19
|
+
import { isCameraDestroyed } from "./lifecycle";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Tuning constants
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
const PAN_BALL_RADIUS = 15; // pixel deflection bound (full = rate 1)
|
|
25
|
+
const MENU_MAX_WIDTH = 260; // matches CameraMenu's CSS cap
|
|
26
|
+
const MENU_MAX_HEIGHT = 300; // matches CameraMenu's min(40vh, 300px) cap
|
|
27
|
+
const QUALITY_MENU_MAX_WIDTH = 220; // matches QualityMenu's CSS cap
|
|
28
|
+
const QUALITY_MENU_MAX_HEIGHT = 220; // matches QualityMenu's min(40vh, 220px) cap
|
|
29
|
+
const MENU_GAP = 4; // trigger-to-menu spacing
|
|
30
|
+
const MENU_EDGE = 8; // minimum inset from the viewport edge
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* Geometry of a portaled dropdown: the CSS size caps of the menu (the
|
|
34
|
+
* helper clamps as if the menu fills them) and which edge of the trigger
|
|
35
|
+
* the menu hangs from: "start" lines the menu's left edge up with the
|
|
36
|
+
* trigger's (camera picker), "end" lines the right edges up (quality
|
|
37
|
+
* button at the action bar's corner).
|
|
38
|
+
*/
|
|
39
|
+
interface MenuAnchor {
|
|
40
|
+
maxWidth: number;
|
|
41
|
+
maxHeight: number;
|
|
42
|
+
align: "start" | "end";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* Fixed-position style for a portaled menu, anchored to its trigger button.
|
|
47
|
+
* Opens downward by default; flips above the trigger when there is not
|
|
48
|
+
* enough room below but there is above, otherwise clamps to the viewport.
|
|
49
|
+
*/
|
|
50
|
+
function computeMenuPosition(
|
|
51
|
+
trigger: HTMLElement,
|
|
52
|
+
anchor: MenuAnchor,
|
|
53
|
+
): React.CSSProperties {
|
|
54
|
+
const rect = trigger.getBoundingClientRect();
|
|
55
|
+
const vw = window.innerWidth;
|
|
56
|
+
const vh = window.innerHeight;
|
|
57
|
+
const width = Math.min(anchor.maxWidth, vw - 2 * MENU_EDGE);
|
|
58
|
+
/* Inline offset from the aligned viewport edge, clamped so a full-width
|
|
59
|
+
menu still fits inside the opposite edge. */
|
|
60
|
+
const inset = Math.max(MENU_EDGE, vw - width - MENU_EDGE);
|
|
61
|
+
const inline: React.CSSProperties =
|
|
62
|
+
anchor.align === "start"
|
|
63
|
+
? { left: Math.min(Math.max(rect.left, MENU_EDGE), inset) }
|
|
64
|
+
: { right: Math.min(Math.max(vw - rect.right, MENU_EDGE), inset) };
|
|
65
|
+
const menuH = Math.min(0.4 * vh, anchor.maxHeight);
|
|
66
|
+
const fitsBelow = rect.bottom + MENU_GAP + menuH <= vh - MENU_EDGE;
|
|
67
|
+
const fitsAbove = rect.top - MENU_GAP - menuH >= MENU_EDGE;
|
|
68
|
+
if (!fitsBelow && fitsAbove) {
|
|
69
|
+
return { ...inline, bottom: vh - rect.top + MENU_GAP };
|
|
70
|
+
}
|
|
71
|
+
const top = Math.min(
|
|
72
|
+
rect.bottom + MENU_GAP,
|
|
73
|
+
Math.max(MENU_EDGE, vh - MENU_EDGE - menuH),
|
|
74
|
+
);
|
|
75
|
+
return { ...inline, top };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/*
|
|
79
|
+
* Shared behaviour for a dropdown portaled to document.body: fixed position
|
|
80
|
+
* computed from the trigger's rect (so tile overflow cannot clip it),
|
|
81
|
+
* re-anchored on window resize/scroll while open, Escape to close with focus
|
|
82
|
+
* returned to the trigger, and portal-aware outside-pointer-down dismissal
|
|
83
|
+
* (menuRef points at the portaled menu, so clicks inside it stay "inside").
|
|
84
|
+
* One menu at a time falls out of the dismissal: opening another menu's
|
|
85
|
+
* trigger is an outside press for this one.
|
|
86
|
+
*/
|
|
87
|
+
function usePortalMenu({ maxWidth, maxHeight, align }: MenuAnchor) {
|
|
88
|
+
const [open, setOpen] = useState(false);
|
|
89
|
+
const [position, setPosition] = useState<React.CSSProperties | null>(null);
|
|
90
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
91
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!open) {
|
|
95
|
+
setPosition(null);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const update = () => {
|
|
99
|
+
const trigger = triggerRef.current;
|
|
100
|
+
if (trigger) {
|
|
101
|
+
setPosition(computeMenuPosition(trigger, { maxWidth, maxHeight, align }));
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
update();
|
|
105
|
+
window.addEventListener("resize", update);
|
|
106
|
+
window.addEventListener("scroll", update, true);
|
|
107
|
+
return () => {
|
|
108
|
+
window.removeEventListener("resize", update);
|
|
109
|
+
window.removeEventListener("scroll", update, true);
|
|
110
|
+
};
|
|
111
|
+
}, [open, maxWidth, maxHeight, align]);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!open) return;
|
|
115
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
116
|
+
if (e.key === "Escape") {
|
|
117
|
+
e.stopPropagation();
|
|
118
|
+
setOpen(false);
|
|
119
|
+
triggerRef.current?.focus();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
123
|
+
if (
|
|
124
|
+
!menuRef.current?.contains(e.target as Node) &&
|
|
125
|
+
!triggerRef.current?.contains(e.target as Node)
|
|
126
|
+
) {
|
|
127
|
+
setOpen(false);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
document.addEventListener("keydown", onKeyDown);
|
|
131
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
132
|
+
return () => {
|
|
133
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
134
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
135
|
+
};
|
|
136
|
+
}, [open]);
|
|
137
|
+
|
|
138
|
+
const toggle = useCallback(() => setOpen((v) => !v), []);
|
|
139
|
+
/** Close after an item pick, returning focus to the trigger. */
|
|
140
|
+
const close = useCallback(() => {
|
|
141
|
+
setOpen(false);
|
|
142
|
+
triggerRef.current?.focus();
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
return { open, toggle, close, position, menuRef, triggerRef };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Round n to the nearest even integer, minimum 2 (H.264 chroma requirement). */
|
|
149
|
+
function toEvenPx(n: number): number {
|
|
150
|
+
return Math.max(2, Math.round(n / 2) * 2);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/*
|
|
154
|
+
* Viewer quality presets, in menu order. Scales mirror the sidecar's
|
|
155
|
+
* QualityPreset mapping (fractions of the camera's operator render size);
|
|
156
|
+
* the target-dims hint floors to even exactly like the plugin does.
|
|
157
|
+
*/
|
|
158
|
+
const QUALITY_PRESETS: ReadonlyArray<{
|
|
159
|
+
preset: QualityPreset;
|
|
160
|
+
label: string;
|
|
161
|
+
scale: number;
|
|
162
|
+
}> = [
|
|
163
|
+
{ preset: QualityPreset.Full, label: "Full", scale: 1.0 },
|
|
164
|
+
{ preset: QualityPreset.ThreeQuarter, label: "3/4", scale: 0.75 },
|
|
165
|
+
{ preset: QualityPreset.Half, label: "1/2", scale: 0.5 },
|
|
166
|
+
{ preset: QualityPreset.Quarter, label: "1/4", scale: 0.25 },
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
function presetDim(operatorDim: number, scale: number): number {
|
|
170
|
+
const v = Math.trunc(operatorDim * scale) & ~1;
|
|
171
|
+
return v < 2 ? 2 : v;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/*
|
|
175
|
+
* Fullscreen helpers. Safari (incl. iPadOS) only exposes the webkit-prefixed
|
|
176
|
+
* API; iOS iPhone has no element fullscreen at all, which `isFullscreenSupported`
|
|
177
|
+
* reports as unsupported so the button hides.
|
|
178
|
+
*/
|
|
179
|
+
type FsDocument = Document & {
|
|
180
|
+
webkitFullscreenElement?: Element | null;
|
|
181
|
+
webkitFullscreenEnabled?: boolean;
|
|
182
|
+
webkitExitFullscreen?: () => Promise<void> | void;
|
|
183
|
+
};
|
|
184
|
+
type FsElement = HTMLElement & {
|
|
185
|
+
webkitRequestFullscreen?: () => Promise<void> | void;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
function isFullscreenSupported(): boolean {
|
|
189
|
+
if (typeof document === "undefined") return false;
|
|
190
|
+
const d = document as FsDocument;
|
|
191
|
+
return Boolean(d.fullscreenEnabled || d.webkitFullscreenEnabled);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function currentFullscreenElement(): Element | null {
|
|
195
|
+
const d = document as FsDocument;
|
|
196
|
+
return d.fullscreenElement ?? d.webkitFullscreenElement ?? null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function requestFullscreen(el: HTMLElement): void {
|
|
200
|
+
const e = el as FsElement;
|
|
201
|
+
void (e.requestFullscreen?.() ?? e.webkitRequestFullscreen?.());
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function exitFullscreen(): void {
|
|
205
|
+
const d = document as FsDocument;
|
|
206
|
+
void (d.exitFullscreen?.() ?? d.webkitExitFullscreen?.());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Public API
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A consumer-supplied action rendered into the feed's top-right action bar.
|
|
215
|
+
* Lets a host page (e.g. the sidecar's spotlight toggle) inject controls
|
|
216
|
+
* without the library knowing what they do.
|
|
217
|
+
*/
|
|
218
|
+
export interface FeedAction {
|
|
219
|
+
/** Stable identity for the React key. */
|
|
220
|
+
id: string;
|
|
221
|
+
/** Accessible label; used for aria-label and the native tooltip. */
|
|
222
|
+
label: string;
|
|
223
|
+
/** Icon node, sized by the action bar (~14px). */
|
|
224
|
+
icon: React.ReactNode;
|
|
225
|
+
/** Toggle state: renders the button highlighted and sets aria-pressed. */
|
|
226
|
+
active?: boolean;
|
|
227
|
+
onClick: () => void;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A hook that yields the live `MediaStream` for a resolved flightId. Must be
|
|
232
|
+
* a stable reference (the same function identity every render) and called
|
|
233
|
+
* unconditionally, per the rules of hooks. Default: the built-in
|
|
234
|
+
* `useKerbcastStream`. Consumers wrap this to inject delayed playout,
|
|
235
|
+
* alternate transports, etc.; the feed stays unaware of what the wrapper
|
|
236
|
+
* does, and keeps binding whatever stream comes back to its `<video>`.
|
|
237
|
+
*
|
|
238
|
+
* A replacement takes over the camera subscription slot: the built-in
|
|
239
|
+
* `useKerbcastStream` (which acquires/releases the slot) does not run when
|
|
240
|
+
* this is supplied, so a replacement must either compose `useKerbcastStream`
|
|
241
|
+
* or acquire the slot itself, or the sidecar is never subscribed and the
|
|
242
|
+
* feed stays black.
|
|
243
|
+
*/
|
|
244
|
+
export type CameraStreamHook = (flightId: number | null) => MediaStream | null;
|
|
245
|
+
|
|
246
|
+
export interface CameraFeedProps {
|
|
247
|
+
/** Override the context client for this feed only. */
|
|
248
|
+
client?: KerbcastClient;
|
|
249
|
+
/**
|
|
250
|
+
* KSP `Part.flightID` of the camera to display. `null` triggers
|
|
251
|
+
* auto-selection: the first live camera in the registry is latched.
|
|
252
|
+
*/
|
|
253
|
+
flightId: number | null;
|
|
254
|
+
/**
|
|
255
|
+
* Called when the user explicitly picks a camera (picker, Next/Prev
|
|
256
|
+
* buttons). Never called on auto-latch.
|
|
257
|
+
*/
|
|
258
|
+
onSelectCamera?: (flightId: number) => void;
|
|
259
|
+
/**
|
|
260
|
+
* Called whenever the camera this feed actually displays changes — including
|
|
261
|
+
* auto-latch and fallback picks, not just explicit selection. The argument
|
|
262
|
+
* is the resolved flightId (null when nothing is shown). Use it to label or
|
|
263
|
+
* annotate the feed by what it is really showing rather than what was
|
|
264
|
+
* requested; the two differ whenever auto-selection kicks in.
|
|
265
|
+
*/
|
|
266
|
+
onDisplayedCameraChange?: (flightId: number | null) => void;
|
|
267
|
+
/** Show resolution + encoder readout. Default false. */
|
|
268
|
+
showDebugInfo?: boolean;
|
|
269
|
+
/**
|
|
270
|
+
* Whether to show animated static. `true`: stall ramps noise in over the
|
|
271
|
+
* held last frame; sourceless path shows live noise. `false`: stall freezes
|
|
272
|
+
* the last frame with a dim scrim and stale badge; sourceless path shows a
|
|
273
|
+
* plain black background. `undefined` (default): auto mode, reads
|
|
274
|
+
* `prefers-reduced-motion: reduce` at mount and follows changes at runtime
|
|
275
|
+
* (reduced motion defaults to off, normal motion defaults to on).
|
|
276
|
+
*/
|
|
277
|
+
showStatic?: boolean;
|
|
278
|
+
/**
|
|
279
|
+
* "auto" (default): ResizeObserver drives `setRenderSize` at a 16:9 crop,
|
|
280
|
+
* debounced 500 ms. "none": no render-size feedback.
|
|
281
|
+
*/
|
|
282
|
+
renderSize?: "auto" | "none";
|
|
283
|
+
/** Message shown when no cameras are available. */
|
|
284
|
+
emptyMessage?: string;
|
|
285
|
+
/**
|
|
286
|
+
* Show a built-in fullscreen toggle that fullscreens this feed's frame.
|
|
287
|
+
* Auto-hidden where the Fullscreen API is unavailable (e.g. iOS Safari,
|
|
288
|
+
* which only fullscreens the bare <video>). Default false.
|
|
289
|
+
*/
|
|
290
|
+
enableFullscreen?: boolean;
|
|
291
|
+
/**
|
|
292
|
+
* Show a built-in Picture-in-Picture toggle for this feed's video.
|
|
293
|
+
* Auto-hidden where `document.pictureInPictureEnabled` is false. Default
|
|
294
|
+
* false.
|
|
295
|
+
*/
|
|
296
|
+
enablePictureInPicture?: boolean;
|
|
297
|
+
/**
|
|
298
|
+
* Show a built-in per-camera quality control in the action bar: Auto plus
|
|
299
|
+
* the resolution presets (full / 3-4 / 1-2 / 1-4 of the operator-configured
|
|
300
|
+
* size), with the camera's effective resolution and a marker when the
|
|
301
|
+
* sidecar's adaptive machinery is throttling below the request. Requests
|
|
302
|
+
* can only lower quality; the resolution change arrives in-band over the
|
|
303
|
+
* existing WebRTC track (the video element is never remounted). Default
|
|
304
|
+
* false.
|
|
305
|
+
*/
|
|
306
|
+
enableQualityControl?: boolean;
|
|
307
|
+
/**
|
|
308
|
+
* Consumer-injected action buttons, rendered left of the built-in
|
|
309
|
+
* fullscreen/PiP controls in the top-right action bar.
|
|
310
|
+
*/
|
|
311
|
+
actions?: FeedAction[];
|
|
312
|
+
/**
|
|
313
|
+
* Consumer-injected action buttons rendered at the far end of the action
|
|
314
|
+
* bar, right of the built-in controls — the natural home for a close/remove
|
|
315
|
+
* button so it sits in the corner.
|
|
316
|
+
*/
|
|
317
|
+
trailingActions?: FeedAction[];
|
|
318
|
+
/**
|
|
319
|
+
* Override how the displayed video stream is sourced for the resolved
|
|
320
|
+
* flightId. Omit (the default) to use the built-in `useKerbcastStream` —
|
|
321
|
+
* unchanged behaviour. When supplied it must be a stable reference passed
|
|
322
|
+
* consistently across renders (it is called as a hook); see
|
|
323
|
+
* {@link CameraStreamHook}.
|
|
324
|
+
*/
|
|
325
|
+
useStream?: CameraStreamHook;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export interface CameraFeedHandle {
|
|
329
|
+
stepCamera(delta: number): void;
|
|
330
|
+
setZoomRate(rate: number): void;
|
|
331
|
+
setPanAxis(axis: "yaw" | "pitch", value: number): void;
|
|
332
|
+
nudgeZoom(deltaSign: number): void;
|
|
333
|
+
nudgePan(yawSign: number, pitchSign: number): void;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// Inner component (reads from context)
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
341
|
+
function CameraFeedInner(
|
|
342
|
+
{
|
|
343
|
+
flightId: requestedFlightId,
|
|
344
|
+
onSelectCamera,
|
|
345
|
+
onDisplayedCameraChange,
|
|
346
|
+
showDebugInfo = false,
|
|
347
|
+
showStatic,
|
|
348
|
+
renderSize = "auto",
|
|
349
|
+
emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed",
|
|
350
|
+
enableFullscreen = false,
|
|
351
|
+
enablePictureInPicture = false,
|
|
352
|
+
enableQualityControl = false,
|
|
353
|
+
actions,
|
|
354
|
+
trailingActions,
|
|
355
|
+
useStream,
|
|
356
|
+
},
|
|
357
|
+
ref,
|
|
358
|
+
) {
|
|
359
|
+
const client = useKerbcastClient();
|
|
360
|
+
const cameras = useKerbcastCameras();
|
|
361
|
+
|
|
362
|
+
/*
|
|
363
|
+
* Reduced-motion auto mode: when `showStatic` prop is undefined, read and
|
|
364
|
+
* track `prefers-reduced-motion: reduce`. Reduced motion defaults to off
|
|
365
|
+
* (no animated noise); normal motion defaults to on.
|
|
366
|
+
*/
|
|
367
|
+
const [reducedMotion, setReducedMotion] = useState(() => {
|
|
368
|
+
if (typeof window === "undefined") return false;
|
|
369
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
370
|
+
});
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
if (showStatic !== undefined) return;
|
|
373
|
+
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
374
|
+
const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches);
|
|
375
|
+
mq.addEventListener("change", handler);
|
|
376
|
+
return () => mq.removeEventListener("change", handler);
|
|
377
|
+
}, [showStatic]);
|
|
378
|
+
const effectiveShowStatic = showStatic === undefined ? !reducedMotion : showStatic;
|
|
379
|
+
|
|
380
|
+
// -------------------------------------------------------------------------
|
|
381
|
+
// Selection model (mirrors gonogo's CameraFeed)
|
|
382
|
+
//
|
|
383
|
+
// Resolution order:
|
|
384
|
+
// 1. Explicit pick, if still present in the list (destroyed or not).
|
|
385
|
+
// 2. Auto: latch the currently-displayed camera. Keep showing it even if
|
|
386
|
+
// destroyed, but only while no other live camera exists (destroyed
|
|
387
|
+
// tombstones never leave the list on their own, so a destroyed latch
|
|
388
|
+
// would otherwise never release). Once a live camera is available,
|
|
389
|
+
// release the latch and auto-pick it.
|
|
390
|
+
// 3. Fresh auto-pick: prefer the first live camera; fall back to first
|
|
391
|
+
// overall only when every camera is destroyed.
|
|
392
|
+
// -------------------------------------------------------------------------
|
|
393
|
+
const displayedRef = useRef<number | null>(null);
|
|
394
|
+
const requestedStillPresent =
|
|
395
|
+
requestedFlightId !== null &&
|
|
396
|
+
cameras.some((c) => c.flightId === requestedFlightId);
|
|
397
|
+
|
|
398
|
+
let flightId: number | null;
|
|
399
|
+
if (requestedStillPresent) {
|
|
400
|
+
flightId = requestedFlightId;
|
|
401
|
+
} else {
|
|
402
|
+
const latched = displayedRef.current;
|
|
403
|
+
const latchedCamera =
|
|
404
|
+
latched !== null ? cameras.find((c) => c.flightId === latched) : undefined;
|
|
405
|
+
const anyLive = cameras.some((c) => !isCameraDestroyed(c));
|
|
406
|
+
const latchedPresent =
|
|
407
|
+
latchedCamera !== undefined &&
|
|
408
|
+
(!isCameraDestroyed(latchedCamera) || !anyLive);
|
|
409
|
+
flightId = latchedPresent
|
|
410
|
+
? latched
|
|
411
|
+
: (cameras.find((c) => !isCameraDestroyed(c))?.flightId ??
|
|
412
|
+
cameras[0]?.flightId ??
|
|
413
|
+
null);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const camera =
|
|
417
|
+
flightId !== null
|
|
418
|
+
? (cameras.find((c) => c.flightId === flightId) ?? null)
|
|
419
|
+
: null;
|
|
420
|
+
|
|
421
|
+
// Latest onDisplayedCameraChange, held in a ref so the commit effect below
|
|
422
|
+
// fires only on a resolved-flightId change, not on every render when the
|
|
423
|
+
// consumer passes an inline callback.
|
|
424
|
+
const onDisplayedRef = useRef(onDisplayedCameraChange);
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
onDisplayedRef.current = onDisplayedCameraChange;
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// Commit on-screen camera for the auto-mode latch, and report which camera
|
|
430
|
+
// is actually displayed so consumers can label by it (auto-picks included).
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
displayedRef.current = flightId;
|
|
433
|
+
onDisplayedRef.current?.(flightId);
|
|
434
|
+
}, [flightId]);
|
|
435
|
+
|
|
436
|
+
// `flightId` here is the RESOLVED id (auto-latch / fallback applied), so a
|
|
437
|
+
// consumer-injected hook never has to duplicate that resolution. The
|
|
438
|
+
// built-in hook is the default. See CameraStreamHook's rules-of-hooks note.
|
|
439
|
+
const resolveStream = useStream ?? useKerbcastStream;
|
|
440
|
+
const stream = resolveStream(flightId);
|
|
441
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
442
|
+
// The feed frame; fullscreen targets this and ResizeObserver measures it.
|
|
443
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
if (videoRef.current && stream) {
|
|
446
|
+
videoRef.current.srcObject = stream;
|
|
447
|
+
}
|
|
448
|
+
}, [stream]);
|
|
449
|
+
|
|
450
|
+
// -------------------------------------------------------------------------
|
|
451
|
+
// Stall presentation. The static is composited in-stream by the SDK's
|
|
452
|
+
// noise pipeline; this forwards the resolved setting to the camera handle
|
|
453
|
+
// and mirrors the handle's stall state for the no-static badge.
|
|
454
|
+
// -------------------------------------------------------------------------
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
if (flightId === null) return;
|
|
457
|
+
client.camera(flightId).setShowStatic(effectiveShowStatic);
|
|
458
|
+
}, [client, flightId, effectiveShowStatic]);
|
|
459
|
+
|
|
460
|
+
const [isStale, setIsStale] = useState(false);
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
if (flightId === null) {
|
|
463
|
+
setIsStale(false);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const cam = client.camera(flightId);
|
|
467
|
+
setIsStale(cam.stalled);
|
|
468
|
+
return cam.on("stall", setIsStale);
|
|
469
|
+
}, [client, flightId]);
|
|
470
|
+
|
|
471
|
+
// -------------------------------------------------------------------------
|
|
472
|
+
// Fullscreen + Picture-in-Picture (opt-in, feature-detected)
|
|
473
|
+
// -------------------------------------------------------------------------
|
|
474
|
+
const fullscreenAvailable = enableFullscreen && isFullscreenSupported();
|
|
475
|
+
const pipAvailable =
|
|
476
|
+
enablePictureInPicture &&
|
|
477
|
+
typeof document !== "undefined" &&
|
|
478
|
+
document.pictureInPictureEnabled === true;
|
|
479
|
+
|
|
480
|
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
481
|
+
const [isPip, setIsPip] = useState(false);
|
|
482
|
+
|
|
483
|
+
// Keep the fullscreen icon in sync however the user enters/exits (Esc, etc).
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
if (!fullscreenAvailable) return;
|
|
486
|
+
const sync = () =>
|
|
487
|
+
setIsFullscreen(currentFullscreenElement() === wrapRef.current);
|
|
488
|
+
document.addEventListener("fullscreenchange", sync);
|
|
489
|
+
document.addEventListener("webkitfullscreenchange", sync);
|
|
490
|
+
sync();
|
|
491
|
+
return () => {
|
|
492
|
+
document.removeEventListener("fullscreenchange", sync);
|
|
493
|
+
document.removeEventListener("webkitfullscreenchange", sync);
|
|
494
|
+
};
|
|
495
|
+
}, [fullscreenAvailable]);
|
|
496
|
+
|
|
497
|
+
// PiP listeners re-attach when the <video> mounts/remounts (flightId change).
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
if (!pipAvailable) return;
|
|
500
|
+
const v = videoRef.current;
|
|
501
|
+
if (!v) return;
|
|
502
|
+
const onEnter = () => setIsPip(true);
|
|
503
|
+
const onLeave = () => setIsPip(false);
|
|
504
|
+
v.addEventListener("enterpictureinpicture", onEnter);
|
|
505
|
+
v.addEventListener("leavepictureinpicture", onLeave);
|
|
506
|
+
return () => {
|
|
507
|
+
v.removeEventListener("enterpictureinpicture", onEnter);
|
|
508
|
+
v.removeEventListener("leavepictureinpicture", onLeave);
|
|
509
|
+
};
|
|
510
|
+
}, [pipAvailable, flightId]);
|
|
511
|
+
|
|
512
|
+
const toggleFullscreen = useCallback(() => {
|
|
513
|
+
const el = wrapRef.current;
|
|
514
|
+
if (!el) return;
|
|
515
|
+
if (currentFullscreenElement()) exitFullscreen();
|
|
516
|
+
else requestFullscreen(el);
|
|
517
|
+
}, []);
|
|
518
|
+
|
|
519
|
+
const togglePip = useCallback(() => {
|
|
520
|
+
const v = videoRef.current;
|
|
521
|
+
if (!v) return;
|
|
522
|
+
if (document.pictureInPictureElement) {
|
|
523
|
+
void document.exitPictureInPicture().catch(() => {});
|
|
524
|
+
} else {
|
|
525
|
+
void v.requestPictureInPicture().catch(() => {});
|
|
526
|
+
}
|
|
527
|
+
}, []);
|
|
528
|
+
|
|
529
|
+
const isDestroyed = camera ? isCameraDestroyed(camera) : false;
|
|
530
|
+
const showPan = camera?.supportsPan && !isDestroyed;
|
|
531
|
+
const showZoom = camera?.supportsZoom && !isDestroyed;
|
|
532
|
+
const supportsPitch =
|
|
533
|
+
!!camera && camera.panPitchMax - camera.panPitchMin > 0;
|
|
534
|
+
|
|
535
|
+
// -------------------------------------------------------------------------
|
|
536
|
+
// Camera selection callbacks (Next/Prev, picker)
|
|
537
|
+
// onSelectCamera is ONLY called on explicit user picks, never on auto-latch.
|
|
538
|
+
// -------------------------------------------------------------------------
|
|
539
|
+
const currentIndex = useMemo(
|
|
540
|
+
() =>
|
|
541
|
+
flightId !== null
|
|
542
|
+
? cameras.findIndex((c) => c.flightId === flightId)
|
|
543
|
+
: -1,
|
|
544
|
+
[cameras, flightId],
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
const stepCamera = useCallback(
|
|
548
|
+
(delta: number) => {
|
|
549
|
+
if (cameras.length === 0) return;
|
|
550
|
+
const base = currentIndex >= 0 ? currentIndex : 0;
|
|
551
|
+
const next = (base + delta + cameras.length) % cameras.length;
|
|
552
|
+
const nextId = cameras[next]?.flightId;
|
|
553
|
+
if (nextId !== undefined) onSelectCamera?.(nextId);
|
|
554
|
+
},
|
|
555
|
+
[cameras, currentIndex, onSelectCamera],
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
// -------------------------------------------------------------------------
|
|
559
|
+
// PanZoomController -- one per displayed camera
|
|
560
|
+
// -------------------------------------------------------------------------
|
|
561
|
+
const controllerRef = useRef<PanZoomController | null>(null);
|
|
562
|
+
const [sliderFov, setSliderFov] = useState(60);
|
|
563
|
+
|
|
564
|
+
useEffect(() => {
|
|
565
|
+
if (flightId === null) {
|
|
566
|
+
controllerRef.current?.dispose();
|
|
567
|
+
controllerRef.current = null;
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const cam = client.camera(flightId);
|
|
571
|
+
const ctrl = new PanZoomController(cam);
|
|
572
|
+
controllerRef.current?.dispose();
|
|
573
|
+
controllerRef.current = ctrl;
|
|
574
|
+
|
|
575
|
+
const unsubSlider = ctrl.onSliderFov(setSliderFov);
|
|
576
|
+
// Seed the slider from the controller's initial (0) -- will be overwritten
|
|
577
|
+
// immediately by syncFromState in the camera-state effect below.
|
|
578
|
+
setSliderFov(ctrl.sliderFov);
|
|
579
|
+
|
|
580
|
+
return () => {
|
|
581
|
+
unsubSlider();
|
|
582
|
+
ctrl.stop();
|
|
583
|
+
ctrl.dispose();
|
|
584
|
+
controllerRef.current = null;
|
|
585
|
+
};
|
|
586
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
587
|
+
}, [client, flightId]);
|
|
588
|
+
|
|
589
|
+
// Sync controller from camera state on every state update.
|
|
590
|
+
useEffect(() => {
|
|
591
|
+
if (!camera || !controllerRef.current) return;
|
|
592
|
+
controllerRef.current.syncFromState(camera);
|
|
593
|
+
}, [camera]);
|
|
594
|
+
|
|
595
|
+
// Stop pan/zoom when the control hides (signal lost / support dropped).
|
|
596
|
+
useEffect(() => {
|
|
597
|
+
if (!showPan) {
|
|
598
|
+
controllerRef.current?.setPanRate(0, 0);
|
|
599
|
+
setBallPos({ x: 0, y: 0 });
|
|
600
|
+
setBallActive(false);
|
|
601
|
+
}
|
|
602
|
+
}, [showPan]);
|
|
603
|
+
useEffect(() => {
|
|
604
|
+
if (!showZoom) controllerRef.current?.setZoomRate(0);
|
|
605
|
+
}, [showZoom]);
|
|
606
|
+
|
|
607
|
+
// -------------------------------------------------------------------------
|
|
608
|
+
// Ball drag state (pixel math lives here)
|
|
609
|
+
// -------------------------------------------------------------------------
|
|
610
|
+
const ballStartRef = useRef({ x: 0, y: 0 });
|
|
611
|
+
const [ballPos, setBallPos] = useState({ x: 0, y: 0 });
|
|
612
|
+
const [ballActive, setBallActive] = useState(false);
|
|
613
|
+
|
|
614
|
+
const handleBallDown = useCallback(
|
|
615
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
616
|
+
if (flightId === null) return;
|
|
617
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
618
|
+
ballStartRef.current = { x: e.clientX, y: e.clientY };
|
|
619
|
+
setBallActive(true);
|
|
620
|
+
controllerRef.current?.setBallDragging(true);
|
|
621
|
+
},
|
|
622
|
+
[flightId],
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
const handleBallMove = useCallback(
|
|
626
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
627
|
+
if (!ballActive) return;
|
|
628
|
+
let dx = e.clientX - ballStartRef.current.x;
|
|
629
|
+
let dy = supportsPitch ? e.clientY - ballStartRef.current.y : 0;
|
|
630
|
+
const mag = Math.hypot(dx, dy);
|
|
631
|
+
if (mag > PAN_BALL_RADIUS) {
|
|
632
|
+
const k = PAN_BALL_RADIUS / mag;
|
|
633
|
+
dx *= k;
|
|
634
|
+
dy *= k;
|
|
635
|
+
}
|
|
636
|
+
setBallPos({ x: dx, y: dy });
|
|
637
|
+
controllerRef.current?.setPanRate(dx / PAN_BALL_RADIUS, -dy / PAN_BALL_RADIUS);
|
|
638
|
+
},
|
|
639
|
+
[ballActive, supportsPitch],
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
const handleBallUp = useCallback(() => {
|
|
643
|
+
setBallActive(false);
|
|
644
|
+
setBallPos({ x: 0, y: 0 });
|
|
645
|
+
controllerRef.current?.setBallDragging(false);
|
|
646
|
+
controllerRef.current?.setPanRate(0, 0);
|
|
647
|
+
}, []);
|
|
648
|
+
|
|
649
|
+
// -------------------------------------------------------------------------
|
|
650
|
+
// Handle API (forwardRef)
|
|
651
|
+
// -------------------------------------------------------------------------
|
|
652
|
+
useImperativeHandle(
|
|
653
|
+
ref,
|
|
654
|
+
() => ({
|
|
655
|
+
stepCamera,
|
|
656
|
+
setZoomRate(rate: number) {
|
|
657
|
+
if (!showZoom) return;
|
|
658
|
+
controllerRef.current?.setZoomRate(rate);
|
|
659
|
+
},
|
|
660
|
+
setPanAxis(axis: "yaw" | "pitch", value: number) {
|
|
661
|
+
if (!showPan) return;
|
|
662
|
+
if (axis === "pitch" && !supportsPitch) return;
|
|
663
|
+
controllerRef.current?.setPanAxis(axis, value);
|
|
664
|
+
},
|
|
665
|
+
nudgeZoom(deltaSign: number) {
|
|
666
|
+
if (!showZoom) return;
|
|
667
|
+
controllerRef.current?.nudgeZoom(deltaSign);
|
|
668
|
+
},
|
|
669
|
+
nudgePan(yawSign: number, pitchSign: number) {
|
|
670
|
+
if (!showPan) return;
|
|
671
|
+
controllerRef.current?.nudgePan(yawSign, pitchSign);
|
|
672
|
+
},
|
|
673
|
+
}),
|
|
674
|
+
[stepCamera, showZoom, showPan, supportsPitch],
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
// -------------------------------------------------------------------------
|
|
678
|
+
// Render-size feedback (ResizeObserver, 500 ms debounce, 16:9 crop)
|
|
679
|
+
// -------------------------------------------------------------------------
|
|
680
|
+
const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
681
|
+
|
|
682
|
+
useEffect(() => {
|
|
683
|
+
if (renderSize !== "auto" || flightId === null) return;
|
|
684
|
+
const el = wrapRef.current;
|
|
685
|
+
if (!el) return;
|
|
686
|
+
const cam = client.camera(flightId);
|
|
687
|
+
|
|
688
|
+
const observer = new ResizeObserver((entries) => {
|
|
689
|
+
const entry = entries[0];
|
|
690
|
+
if (!entry) return;
|
|
691
|
+
const { width } = entry.contentRect;
|
|
692
|
+
if (resizeTimerRef.current !== null) clearTimeout(resizeTimerRef.current);
|
|
693
|
+
resizeTimerRef.current = setTimeout(() => {
|
|
694
|
+
const w = toEvenPx(width);
|
|
695
|
+
const h = toEvenPx((width * 9) / 16);
|
|
696
|
+
void cam.setRenderSize(w, h);
|
|
697
|
+
}, 500);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
observer.observe(el);
|
|
701
|
+
|
|
702
|
+
return () => {
|
|
703
|
+
observer.disconnect();
|
|
704
|
+
if (resizeTimerRef.current !== null) clearTimeout(resizeTimerRef.current);
|
|
705
|
+
};
|
|
706
|
+
}, [client, flightId, renderSize]);
|
|
707
|
+
|
|
708
|
+
// -------------------------------------------------------------------------
|
|
709
|
+
// UI state
|
|
710
|
+
// -------------------------------------------------------------------------
|
|
711
|
+
const bitrateLabel =
|
|
712
|
+
camera && camera.encoderBitrateBps > 0
|
|
713
|
+
? ` · ${Math.round(camera.encoderBitrateBps / 1000)}kbps`
|
|
714
|
+
: "";
|
|
715
|
+
const adaptiveLabel =
|
|
716
|
+
camera && camera.renderWidth < camera.operatorWidth ? " · adaptive" : "";
|
|
717
|
+
|
|
718
|
+
const hasCameras = cameras.length > 0;
|
|
719
|
+
const canStep = cameras.length > 1;
|
|
720
|
+
const menuId = useId();
|
|
721
|
+
const [chromePinned, setChromePinned] = useState(false);
|
|
722
|
+
const cameraMenu = usePortalMenu({
|
|
723
|
+
maxWidth: MENU_MAX_WIDTH,
|
|
724
|
+
maxHeight: MENU_MAX_HEIGHT,
|
|
725
|
+
align: "start",
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
const cameraLabel = useMemo(() => buildCameraLabeler(cameras), [cameras]);
|
|
729
|
+
const title = camera ? cameraLabel(camera) : "Camera Feed";
|
|
730
|
+
|
|
731
|
+
// -------------------------------------------------------------------------
|
|
732
|
+
// Viewer quality control (opt-in). A built-in action button + menu: Auto
|
|
733
|
+
// plus the resolution presets. Requested state = camera.viewerQuality
|
|
734
|
+
// (authoritative, broadcast by the sidecar so every UI agrees); effective
|
|
735
|
+
// state = renderWidth/Height; qualityLimitedBy marks adaptive throttling.
|
|
736
|
+
// -------------------------------------------------------------------------
|
|
737
|
+
const qualityAvailable =
|
|
738
|
+
enableQualityControl && flightId !== null && camera !== null;
|
|
739
|
+
const qualityThrottled = Boolean(camera?.qualityLimitedBy);
|
|
740
|
+
const qualityMenuId = useId();
|
|
741
|
+
// Same portal/anchor/dismissal machinery as the camera menu, hung from
|
|
742
|
+
// the right edge of its action-bar trigger.
|
|
743
|
+
const qualityMenu = usePortalMenu({
|
|
744
|
+
maxWidth: QUALITY_MENU_MAX_WIDTH,
|
|
745
|
+
maxHeight: QUALITY_MENU_MAX_HEIGHT,
|
|
746
|
+
align: "end",
|
|
747
|
+
});
|
|
748
|
+
const closeQualityMenu = qualityMenu.close;
|
|
749
|
+
|
|
750
|
+
const selectQuality = useCallback(
|
|
751
|
+
(preset: QualityPreset | null) => {
|
|
752
|
+
if (flightId === null) return;
|
|
753
|
+
void client.camera(flightId).setQuality(preset);
|
|
754
|
+
closeQualityMenu();
|
|
755
|
+
},
|
|
756
|
+
[client, flightId, closeQualityMenu],
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
const topOverlay = (
|
|
760
|
+
<TopOverlay>
|
|
761
|
+
<TitleRow>
|
|
762
|
+
<TopTitle>
|
|
763
|
+
{hasCameras ? (
|
|
764
|
+
<TitleButton
|
|
765
|
+
ref={cameraMenu.triggerRef}
|
|
766
|
+
type="button"
|
|
767
|
+
aria-haspopup="menu"
|
|
768
|
+
aria-expanded={cameraMenu.open}
|
|
769
|
+
aria-controls={menuId}
|
|
770
|
+
onClick={cameraMenu.toggle}
|
|
771
|
+
>
|
|
772
|
+
<TitleButton__Text>{title}</TitleButton__Text>
|
|
773
|
+
<ChevronDownIcon aria-hidden="true" />
|
|
774
|
+
</TitleButton>
|
|
775
|
+
) : (
|
|
776
|
+
title
|
|
777
|
+
)}
|
|
778
|
+
</TopTitle>
|
|
779
|
+
{hasCameras && (
|
|
780
|
+
<StepButtons>
|
|
781
|
+
<OverlayIconButton
|
|
782
|
+
type="button"
|
|
783
|
+
aria-label="Previous camera"
|
|
784
|
+
disabled={!canStep}
|
|
785
|
+
onClick={() => stepCamera(-1)}
|
|
786
|
+
>
|
|
787
|
+
‹
|
|
788
|
+
</OverlayIconButton>
|
|
789
|
+
<OverlayIconButton
|
|
790
|
+
type="button"
|
|
791
|
+
aria-label="Next camera"
|
|
792
|
+
disabled={!canStep}
|
|
793
|
+
onClick={() => stepCamera(1)}
|
|
794
|
+
>
|
|
795
|
+
›
|
|
796
|
+
</OverlayIconButton>
|
|
797
|
+
</StepButtons>
|
|
798
|
+
)}
|
|
799
|
+
</TitleRow>
|
|
800
|
+
|
|
801
|
+
{cameraMenu.open &&
|
|
802
|
+
hasCameras &&
|
|
803
|
+
cameraMenu.position &&
|
|
804
|
+
createPortal(
|
|
805
|
+
<CameraMenu
|
|
806
|
+
ref={cameraMenu.menuRef}
|
|
807
|
+
id={menuId}
|
|
808
|
+
role="menu"
|
|
809
|
+
aria-label="Camera"
|
|
810
|
+
style={cameraMenu.position}
|
|
811
|
+
>
|
|
812
|
+
{cameras.map((c) => (
|
|
813
|
+
<CameraMenuItem
|
|
814
|
+
key={c.flightId}
|
|
815
|
+
type="button"
|
|
816
|
+
role="menuitemradio"
|
|
817
|
+
aria-checked={c.flightId === flightId}
|
|
818
|
+
$selected={c.flightId === flightId}
|
|
819
|
+
onClick={() => {
|
|
820
|
+
onSelectCamera?.(c.flightId);
|
|
821
|
+
cameraMenu.close();
|
|
822
|
+
}}
|
|
823
|
+
>
|
|
824
|
+
{cameraLabel(c)} ({c.vesselName})
|
|
825
|
+
{isCameraDestroyed(c) ? " - signal lost" : ""}
|
|
826
|
+
</CameraMenuItem>
|
|
827
|
+
))}
|
|
828
|
+
</CameraMenu>,
|
|
829
|
+
document.body,
|
|
830
|
+
)}
|
|
831
|
+
|
|
832
|
+
{showDebugInfo &&
|
|
833
|
+
(camera ? (
|
|
834
|
+
<TopMeta>
|
|
835
|
+
{camera.vesselName} · {camera.renderWidth}×{camera.renderHeight}
|
|
836
|
+
{bitrateLabel}
|
|
837
|
+
{adaptiveLabel}
|
|
838
|
+
</TopMeta>
|
|
839
|
+
) : (
|
|
840
|
+
<TopMeta>no cameras on this vessel</TopMeta>
|
|
841
|
+
))}
|
|
842
|
+
</TopOverlay>
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
const renderAction = (a: FeedAction) => (
|
|
846
|
+
<OverlayIconButton
|
|
847
|
+
key={a.id}
|
|
848
|
+
type="button"
|
|
849
|
+
aria-label={a.label}
|
|
850
|
+
aria-pressed={a.active ?? undefined}
|
|
851
|
+
title={a.label}
|
|
852
|
+
$active={a.active ?? false}
|
|
853
|
+
onClick={a.onClick}
|
|
854
|
+
>
|
|
855
|
+
{a.icon}
|
|
856
|
+
</OverlayIconButton>
|
|
857
|
+
);
|
|
858
|
+
|
|
859
|
+
const builtInActions =
|
|
860
|
+
flightId !== null && (pipAvailable || fullscreenAvailable || qualityAvailable);
|
|
861
|
+
const hasActionBar =
|
|
862
|
+
(actions && actions.length > 0) ||
|
|
863
|
+
(trailingActions && trailingActions.length > 0) ||
|
|
864
|
+
builtInActions;
|
|
865
|
+
const actionBar = hasActionBar ? (
|
|
866
|
+
<ActionBar>
|
|
867
|
+
{actions?.map(renderAction)}
|
|
868
|
+
{qualityAvailable && (
|
|
869
|
+
<OverlayIconButton
|
|
870
|
+
ref={qualityMenu.triggerRef}
|
|
871
|
+
type="button"
|
|
872
|
+
aria-label="Quality"
|
|
873
|
+
aria-haspopup="menu"
|
|
874
|
+
aria-expanded={qualityMenu.open}
|
|
875
|
+
aria-controls={qualityMenuId}
|
|
876
|
+
title={
|
|
877
|
+
qualityThrottled
|
|
878
|
+
? "Quality (throttled by adaptive performance)"
|
|
879
|
+
: "Quality"
|
|
880
|
+
}
|
|
881
|
+
$active={qualityMenu.open}
|
|
882
|
+
onClick={qualityMenu.toggle}
|
|
883
|
+
>
|
|
884
|
+
<QualityIcon />
|
|
885
|
+
{qualityThrottled && <ThrottledDot aria-hidden="true" />}
|
|
886
|
+
</OverlayIconButton>
|
|
887
|
+
)}
|
|
888
|
+
{flightId !== null && pipAvailable && (
|
|
889
|
+
<OverlayIconButton
|
|
890
|
+
type="button"
|
|
891
|
+
aria-label={isPip ? "Exit picture in picture" : "Picture in picture"}
|
|
892
|
+
aria-pressed={isPip}
|
|
893
|
+
title={isPip ? "Exit picture in picture" : "Picture in picture"}
|
|
894
|
+
$active={isPip}
|
|
895
|
+
onClick={togglePip}
|
|
896
|
+
>
|
|
897
|
+
<PictureInPictureIcon />
|
|
898
|
+
</OverlayIconButton>
|
|
899
|
+
)}
|
|
900
|
+
{flightId !== null && fullscreenAvailable && (
|
|
901
|
+
<OverlayIconButton
|
|
902
|
+
type="button"
|
|
903
|
+
aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
|
904
|
+
aria-pressed={isFullscreen}
|
|
905
|
+
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
|
906
|
+
$active={isFullscreen}
|
|
907
|
+
onClick={toggleFullscreen}
|
|
908
|
+
>
|
|
909
|
+
{isFullscreen ? <FullscreenExitIcon /> : <FullscreenEnterIcon />}
|
|
910
|
+
</OverlayIconButton>
|
|
911
|
+
)}
|
|
912
|
+
{trailingActions?.map(renderAction)}
|
|
913
|
+
</ActionBar>
|
|
914
|
+
) : null;
|
|
915
|
+
|
|
916
|
+
return (
|
|
917
|
+
<Stage ref={wrapRef} $pinned={chromePinned}>
|
|
918
|
+
{flightId === null ? (
|
|
919
|
+
<>
|
|
920
|
+
<Empty>{emptyMessage}</Empty>
|
|
921
|
+
{topOverlay}
|
|
922
|
+
{actionBar}
|
|
923
|
+
</>
|
|
924
|
+
) : (
|
|
925
|
+
<>
|
|
926
|
+
<StyledVideo
|
|
927
|
+
ref={videoRef}
|
|
928
|
+
autoPlay
|
|
929
|
+
playsInline
|
|
930
|
+
muted
|
|
931
|
+
controls={false}
|
|
932
|
+
onClick={() => setChromePinned((v) => !v)}
|
|
933
|
+
/>
|
|
934
|
+
{topOverlay}
|
|
935
|
+
{actionBar}
|
|
936
|
+
{qualityAvailable &&
|
|
937
|
+
qualityMenu.open &&
|
|
938
|
+
qualityMenu.position &&
|
|
939
|
+
camera &&
|
|
940
|
+
createPortal(
|
|
941
|
+
<QualityMenu
|
|
942
|
+
ref={qualityMenu.menuRef}
|
|
943
|
+
id={qualityMenuId}
|
|
944
|
+
role="menu"
|
|
945
|
+
aria-label="Quality"
|
|
946
|
+
style={qualityMenu.position}
|
|
947
|
+
>
|
|
948
|
+
<CameraMenuItem
|
|
949
|
+
type="button"
|
|
950
|
+
role="menuitemradio"
|
|
951
|
+
aria-checked={camera.viewerQuality == null}
|
|
952
|
+
$selected={camera.viewerQuality == null}
|
|
953
|
+
onClick={() => selectQuality(null)}
|
|
954
|
+
>
|
|
955
|
+
Auto
|
|
956
|
+
</CameraMenuItem>
|
|
957
|
+
{QUALITY_PRESETS.map(({ preset, label, scale }) => (
|
|
958
|
+
<CameraMenuItem
|
|
959
|
+
key={preset}
|
|
960
|
+
type="button"
|
|
961
|
+
role="menuitemradio"
|
|
962
|
+
aria-checked={camera.viewerQuality === preset}
|
|
963
|
+
$selected={camera.viewerQuality === preset}
|
|
964
|
+
onClick={() => selectQuality(preset)}
|
|
965
|
+
>
|
|
966
|
+
{label} ({presetDim(camera.operatorWidth, scale)}×
|
|
967
|
+
{presetDim(camera.operatorHeight, scale)})
|
|
968
|
+
</CameraMenuItem>
|
|
969
|
+
))}
|
|
970
|
+
<QualityMeta role="status">
|
|
971
|
+
now {camera.renderWidth}×{camera.renderHeight}
|
|
972
|
+
{qualityThrottled ? " · throttled" : ""}
|
|
973
|
+
</QualityMeta>
|
|
974
|
+
</QualityMenu>,
|
|
975
|
+
document.body,
|
|
976
|
+
)}
|
|
977
|
+
{isDestroyed && (
|
|
978
|
+
<SignalLostOverlay role="status" aria-label="Signal lost">
|
|
979
|
+
<SignalLostText $animated={effectiveShowStatic}>SIGNAL LOST</SignalLostText>
|
|
980
|
+
</SignalLostOverlay>
|
|
981
|
+
)}
|
|
982
|
+
{!effectiveShowStatic && isStale && !isDestroyed && (
|
|
983
|
+
<>
|
|
984
|
+
<StaleScrim aria-hidden="true" />
|
|
985
|
+
<StaleBadge role="status" aria-label="Feed stale">
|
|
986
|
+
<StaleIcon />
|
|
987
|
+
Stale
|
|
988
|
+
</StaleBadge>
|
|
989
|
+
</>
|
|
990
|
+
)}
|
|
991
|
+
{showZoom && (
|
|
992
|
+
<ZoomControlsWrap>
|
|
993
|
+
<ZoomButton
|
|
994
|
+
type="button"
|
|
995
|
+
aria-label="Zoom in"
|
|
996
|
+
$pos="top"
|
|
997
|
+
onPointerDown={() =>
|
|
998
|
+
controllerRef.current?.setZoomRate(1)
|
|
999
|
+
}
|
|
1000
|
+
onPointerUp={() => controllerRef.current?.setZoomRate(0)}
|
|
1001
|
+
onPointerLeave={() => controllerRef.current?.setZoomRate(0)}
|
|
1002
|
+
onPointerCancel={() => controllerRef.current?.setZoomRate(0)}
|
|
1003
|
+
onClick={(e) => {
|
|
1004
|
+
if (e.detail === 0) controllerRef.current?.nudgeZoom(-1);
|
|
1005
|
+
}}
|
|
1006
|
+
>
|
|
1007
|
+
+
|
|
1008
|
+
</ZoomButton>
|
|
1009
|
+
<FovSlider
|
|
1010
|
+
type="range"
|
|
1011
|
+
aria-label="Zoom"
|
|
1012
|
+
min={camera.fovMin}
|
|
1013
|
+
max={camera.fovMax}
|
|
1014
|
+
step={0.5}
|
|
1015
|
+
value={sliderFov}
|
|
1016
|
+
onChange={(e) => {
|
|
1017
|
+
const v = Number(e.target.value);
|
|
1018
|
+
controllerRef.current?.fovSliderInput(v);
|
|
1019
|
+
}}
|
|
1020
|
+
onPointerDown={() => {
|
|
1021
|
+
controllerRef.current?.setFovSliderDragging(true);
|
|
1022
|
+
}}
|
|
1023
|
+
onPointerUp={() => {
|
|
1024
|
+
controllerRef.current?.setFovSliderDragging(false);
|
|
1025
|
+
}}
|
|
1026
|
+
onPointerCancel={() => {
|
|
1027
|
+
controllerRef.current?.setFovSliderDragging(false);
|
|
1028
|
+
}}
|
|
1029
|
+
/>
|
|
1030
|
+
<ZoomButton
|
|
1031
|
+
type="button"
|
|
1032
|
+
aria-label="Zoom out"
|
|
1033
|
+
$pos="bottom"
|
|
1034
|
+
onPointerDown={() => controllerRef.current?.setZoomRate(-1)}
|
|
1035
|
+
onPointerUp={() => controllerRef.current?.setZoomRate(0)}
|
|
1036
|
+
onPointerLeave={() => controllerRef.current?.setZoomRate(0)}
|
|
1037
|
+
onPointerCancel={() => controllerRef.current?.setZoomRate(0)}
|
|
1038
|
+
onClick={(e) => {
|
|
1039
|
+
if (e.detail === 0) controllerRef.current?.nudgeZoom(1);
|
|
1040
|
+
}}
|
|
1041
|
+
>
|
|
1042
|
+
−
|
|
1043
|
+
</ZoomButton>
|
|
1044
|
+
</ZoomControlsWrap>
|
|
1045
|
+
)}
|
|
1046
|
+
{showPan && (
|
|
1047
|
+
<PanControl role="group" aria-label="Pan camera">
|
|
1048
|
+
<PanArrow
|
|
1049
|
+
type="button"
|
|
1050
|
+
$dir="up"
|
|
1051
|
+
aria-label="Pan up"
|
|
1052
|
+
disabled={!supportsPitch}
|
|
1053
|
+
onClick={() => controllerRef.current?.nudgePan(0, 1)}
|
|
1054
|
+
>
|
|
1055
|
+
▲
|
|
1056
|
+
</PanArrow>
|
|
1057
|
+
<PanArrow
|
|
1058
|
+
type="button"
|
|
1059
|
+
$dir="down"
|
|
1060
|
+
aria-label="Pan down"
|
|
1061
|
+
disabled={!supportsPitch}
|
|
1062
|
+
onClick={() => controllerRef.current?.nudgePan(0, -1)}
|
|
1063
|
+
>
|
|
1064
|
+
▼
|
|
1065
|
+
</PanArrow>
|
|
1066
|
+
<PanArrow
|
|
1067
|
+
type="button"
|
|
1068
|
+
$dir="left"
|
|
1069
|
+
aria-label="Pan left"
|
|
1070
|
+
onClick={() => controllerRef.current?.nudgePan(-1, 0)}
|
|
1071
|
+
>
|
|
1072
|
+
◀
|
|
1073
|
+
</PanArrow>
|
|
1074
|
+
<PanArrow
|
|
1075
|
+
type="button"
|
|
1076
|
+
$dir="right"
|
|
1077
|
+
aria-label="Pan right"
|
|
1078
|
+
onClick={() => controllerRef.current?.nudgePan(1, 0)}
|
|
1079
|
+
>
|
|
1080
|
+
▶
|
|
1081
|
+
</PanArrow>
|
|
1082
|
+
<PanBall
|
|
1083
|
+
aria-hidden="true"
|
|
1084
|
+
title="Drag to pan"
|
|
1085
|
+
onPointerDown={handleBallDown}
|
|
1086
|
+
onPointerMove={handleBallMove}
|
|
1087
|
+
onPointerUp={handleBallUp}
|
|
1088
|
+
onPointerCancel={handleBallUp}
|
|
1089
|
+
style={{
|
|
1090
|
+
transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
|
|
1091
|
+
}}
|
|
1092
|
+
/>
|
|
1093
|
+
</PanControl>
|
|
1094
|
+
)}
|
|
1095
|
+
</>
|
|
1096
|
+
)}
|
|
1097
|
+
</Stage>
|
|
1098
|
+
);
|
|
1099
|
+
},
|
|
1100
|
+
);
|
|
1101
|
+
|
|
1102
|
+
// ---------------------------------------------------------------------------
|
|
1103
|
+
// Public CameraFeed - wraps inner in a nested provider when client prop given
|
|
1104
|
+
// ---------------------------------------------------------------------------
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Display a live kerbcast camera feed with pan/zoom controls and camera picker.
|
|
1108
|
+
* Must be rendered inside a `KerbcastProvider` unless the `client` prop is
|
|
1109
|
+
* provided (which creates an implicit inner provider for this feed only).
|
|
1110
|
+
*/
|
|
1111
|
+
export const CameraFeed = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
1112
|
+
function CameraFeed({ client, ...rest }, ref) {
|
|
1113
|
+
if (client) {
|
|
1114
|
+
return (
|
|
1115
|
+
<KerbcastProvider client={client}>
|
|
1116
|
+
<CameraFeedInner ref={ref} {...rest} />
|
|
1117
|
+
</KerbcastProvider>
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
return <CameraFeedInner ref={ref} {...rest} />;
|
|
1121
|
+
},
|
|
1122
|
+
);
|
|
1123
|
+
|
|
1124
|
+
// ---------------------------------------------------------------------------
|
|
1125
|
+
// Inline UI primitives (replacing @gonogo/ui dependencies)
|
|
1126
|
+
// ---------------------------------------------------------------------------
|
|
1127
|
+
|
|
1128
|
+
function ChevronDownIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
1129
|
+
return (
|
|
1130
|
+
<svg
|
|
1131
|
+
viewBox="0 0 16 16"
|
|
1132
|
+
fill="currentColor"
|
|
1133
|
+
aria-hidden="true"
|
|
1134
|
+
{...props}
|
|
1135
|
+
>
|
|
1136
|
+
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth={1.5} fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
1137
|
+
</svg>
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const iconProps = {
|
|
1142
|
+
viewBox: "0 0 16 16",
|
|
1143
|
+
width: 14,
|
|
1144
|
+
height: 14,
|
|
1145
|
+
fill: "none",
|
|
1146
|
+
stroke: "currentColor",
|
|
1147
|
+
strokeWidth: 1.6,
|
|
1148
|
+
strokeLinecap: "round" as const,
|
|
1149
|
+
strokeLinejoin: "round" as const,
|
|
1150
|
+
"aria-hidden": true as const,
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1153
|
+
function FullscreenEnterIcon() {
|
|
1154
|
+
return (
|
|
1155
|
+
<svg {...iconProps}>
|
|
1156
|
+
<path d="M2 6V2.5h3.5M14 6V2.5h-3.5M2 10v3.5h3.5M14 10v3.5h-3.5" />
|
|
1157
|
+
</svg>
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function FullscreenExitIcon() {
|
|
1162
|
+
return (
|
|
1163
|
+
<svg {...iconProps}>
|
|
1164
|
+
<path d="M5.5 2v3.5H2M10.5 2v3.5H14M5.5 14v-3.5H2M10.5 14v-3.5H14" />
|
|
1165
|
+
</svg>
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/* Quality control: three horizontal slider rails with offset knobs. */
|
|
1170
|
+
function QualityIcon() {
|
|
1171
|
+
return (
|
|
1172
|
+
<svg {...iconProps}>
|
|
1173
|
+
<path d="M2 4.5h12M2 8h12M2 11.5h12" />
|
|
1174
|
+
<circle cx="10.5" cy="4.5" r="1.6" fill="currentColor" stroke="none" />
|
|
1175
|
+
<circle cx="5.5" cy="8" r="1.6" fill="currentColor" stroke="none" />
|
|
1176
|
+
<circle cx="8.5" cy="11.5" r="1.6" fill="currentColor" stroke="none" />
|
|
1177
|
+
</svg>
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/* Stale badge: fading signal bars (tallest dimmed). */
|
|
1182
|
+
function StaleIcon() {
|
|
1183
|
+
return (
|
|
1184
|
+
<svg
|
|
1185
|
+
viewBox="0 0 16 16"
|
|
1186
|
+
width={10}
|
|
1187
|
+
height={10}
|
|
1188
|
+
fill="currentColor"
|
|
1189
|
+
stroke="none"
|
|
1190
|
+
aria-hidden="true"
|
|
1191
|
+
>
|
|
1192
|
+
<rect x="2" y="9" width="2.5" height="5" />
|
|
1193
|
+
<rect x="6.5" y="6" width="2.5" height="8" />
|
|
1194
|
+
<rect x="11" y="3" width="2.5" height="11" opacity="0.35" />
|
|
1195
|
+
</svg>
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function PictureInPictureIcon() {
|
|
1200
|
+
return (
|
|
1201
|
+
<svg {...iconProps}>
|
|
1202
|
+
<rect x="2" y="3" width="12" height="10" rx="1" />
|
|
1203
|
+
<rect x="8" y="8" width="5" height="4" rx="0.5" fill="currentColor" stroke="none" />
|
|
1204
|
+
</svg>
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// ---------------------------------------------------------------------------
|
|
1209
|
+
// Styled components
|
|
1210
|
+
// ---------------------------------------------------------------------------
|
|
1211
|
+
|
|
1212
|
+
const PanControl = styled.div`
|
|
1213
|
+
position: absolute;
|
|
1214
|
+
bottom: 10px;
|
|
1215
|
+
right: 10px;
|
|
1216
|
+
width: 52px;
|
|
1217
|
+
height: 52px;
|
|
1218
|
+
opacity: 0;
|
|
1219
|
+
transition: opacity 0.15s;
|
|
1220
|
+
touch-action: none;
|
|
1221
|
+
|
|
1222
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1223
|
+
transition: none;
|
|
1224
|
+
}
|
|
1225
|
+
`;
|
|
1226
|
+
|
|
1227
|
+
const PanArrow = styled.button<{ $dir: "up" | "down" | "left" | "right" }>`
|
|
1228
|
+
position: absolute;
|
|
1229
|
+
width: 16px;
|
|
1230
|
+
height: 16px;
|
|
1231
|
+
display: flex;
|
|
1232
|
+
align-items: center;
|
|
1233
|
+
justify-content: center;
|
|
1234
|
+
padding: 0;
|
|
1235
|
+
font-size: 11px;
|
|
1236
|
+
line-height: 1;
|
|
1237
|
+
color: #fff;
|
|
1238
|
+
opacity: 0.5;
|
|
1239
|
+
background: none;
|
|
1240
|
+
border: none;
|
|
1241
|
+
cursor: pointer;
|
|
1242
|
+
touch-action: none;
|
|
1243
|
+
text-shadow:
|
|
1244
|
+
0 0 3px rgba(0, 0, 0, 0.9),
|
|
1245
|
+
0 1px 2px rgba(0, 0, 0, 0.8);
|
|
1246
|
+
|
|
1247
|
+
${(p) =>
|
|
1248
|
+
p.$dir === "up"
|
|
1249
|
+
? "top: 0; left: 50%; transform: translateX(-50%);"
|
|
1250
|
+
: p.$dir === "down"
|
|
1251
|
+
? "bottom: 0; left: 50%; transform: translateX(-50%);"
|
|
1252
|
+
: p.$dir === "left"
|
|
1253
|
+
? "left: 0; top: 50%; transform: translateY(-50%);"
|
|
1254
|
+
: "right: 0; top: 50%; transform: translateY(-50%);"}
|
|
1255
|
+
|
|
1256
|
+
@media (hover: hover) {
|
|
1257
|
+
&:hover:not(:disabled) {
|
|
1258
|
+
opacity: 1;
|
|
1259
|
+
color: var(--kerbcast-accent, #00ff88);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
&:disabled {
|
|
1263
|
+
opacity: 0.3;
|
|
1264
|
+
cursor: default;
|
|
1265
|
+
}
|
|
1266
|
+
&:focus-visible {
|
|
1267
|
+
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
1268
|
+
outline-offset: 2px;
|
|
1269
|
+
}
|
|
1270
|
+
`;
|
|
1271
|
+
|
|
1272
|
+
const PanBall = styled.div`
|
|
1273
|
+
position: absolute;
|
|
1274
|
+
top: 50%;
|
|
1275
|
+
left: 50%;
|
|
1276
|
+
width: 12px;
|
|
1277
|
+
height: 12px;
|
|
1278
|
+
margin: -6px 0 0 -6px;
|
|
1279
|
+
border-radius: 50%;
|
|
1280
|
+
background: radial-gradient(circle at 35% 30%, #ffffff, #d6dbe1);
|
|
1281
|
+
box-shadow:
|
|
1282
|
+
0 0 0 1px rgba(0, 0, 0, 0.5),
|
|
1283
|
+
0 0 4px rgba(255, 255, 255, 0.4);
|
|
1284
|
+
cursor: grab;
|
|
1285
|
+
touch-action: none;
|
|
1286
|
+
|
|
1287
|
+
&:active {
|
|
1288
|
+
cursor: grabbing;
|
|
1289
|
+
}
|
|
1290
|
+
`;
|
|
1291
|
+
|
|
1292
|
+
const ZoomControlsWrap = styled.div`
|
|
1293
|
+
position: absolute;
|
|
1294
|
+
bottom: 8px;
|
|
1295
|
+
left: 8px;
|
|
1296
|
+
width: 30px;
|
|
1297
|
+
display: flex;
|
|
1298
|
+
flex-direction: column;
|
|
1299
|
+
align-items: stretch;
|
|
1300
|
+
background: rgba(0, 0, 0, 0.6);
|
|
1301
|
+
border: 1px solid rgba(255, 255, 255, 0.5);
|
|
1302
|
+
opacity: 0;
|
|
1303
|
+
transition: opacity 0.15s;
|
|
1304
|
+
|
|
1305
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1306
|
+
transition: none;
|
|
1307
|
+
}
|
|
1308
|
+
`;
|
|
1309
|
+
|
|
1310
|
+
const ZoomButton = styled.button<{ $pos: "top" | "bottom" }>`
|
|
1311
|
+
width: 100%;
|
|
1312
|
+
height: 26px;
|
|
1313
|
+
display: flex;
|
|
1314
|
+
align-items: center;
|
|
1315
|
+
justify-content: center;
|
|
1316
|
+
padding: 0;
|
|
1317
|
+
background: transparent;
|
|
1318
|
+
border: none;
|
|
1319
|
+
border-radius: 0;
|
|
1320
|
+
color: #fff;
|
|
1321
|
+
font-size: 1rem;
|
|
1322
|
+
cursor: pointer;
|
|
1323
|
+
${(p) =>
|
|
1324
|
+
p.$pos === "top"
|
|
1325
|
+
? "border-bottom: 1px solid rgba(255, 255, 255, 0.3);"
|
|
1326
|
+
: "border-top: 1px solid rgba(255, 255, 255, 0.3);"}
|
|
1327
|
+
|
|
1328
|
+
@media (hover: hover) {
|
|
1329
|
+
&:hover {
|
|
1330
|
+
color: #fff;
|
|
1331
|
+
background: rgba(255, 255, 255, 0.15);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
&:focus-visible {
|
|
1336
|
+
outline: 2px solid #fff;
|
|
1337
|
+
outline-offset: -2px;
|
|
1338
|
+
}
|
|
1339
|
+
`;
|
|
1340
|
+
|
|
1341
|
+
const TopOverlay = styled.div`
|
|
1342
|
+
position: absolute;
|
|
1343
|
+
top: 0;
|
|
1344
|
+
left: 0;
|
|
1345
|
+
right: 0;
|
|
1346
|
+
z-index: 2;
|
|
1347
|
+
display: flex;
|
|
1348
|
+
flex-direction: column;
|
|
1349
|
+
gap: 3px;
|
|
1350
|
+
padding: 6px 8px 14px;
|
|
1351
|
+
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.78), rgba(0, 0, 0, 0));
|
|
1352
|
+
opacity: 0;
|
|
1353
|
+
pointer-events: none;
|
|
1354
|
+
transition: opacity 0.15s;
|
|
1355
|
+
|
|
1356
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1357
|
+
transition: none;
|
|
1358
|
+
}
|
|
1359
|
+
`;
|
|
1360
|
+
|
|
1361
|
+
const TitleRow = styled.div`
|
|
1362
|
+
display: flex;
|
|
1363
|
+
align-items: flex-start;
|
|
1364
|
+
justify-content: space-between;
|
|
1365
|
+
gap: 8px;
|
|
1366
|
+
`;
|
|
1367
|
+
|
|
1368
|
+
const TopTitle = styled.h3`
|
|
1369
|
+
margin: 0;
|
|
1370
|
+
min-width: 0;
|
|
1371
|
+
font-size: var(--font-size-xs, 11px);
|
|
1372
|
+
font-weight: 600;
|
|
1373
|
+
letter-spacing: 0.12em;
|
|
1374
|
+
text-transform: uppercase;
|
|
1375
|
+
color: #fff;
|
|
1376
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
1377
|
+
`;
|
|
1378
|
+
|
|
1379
|
+
const TitleButton = styled.button`
|
|
1380
|
+
display: inline-flex;
|
|
1381
|
+
align-items: center;
|
|
1382
|
+
gap: 4px;
|
|
1383
|
+
max-width: 100%;
|
|
1384
|
+
margin: 0;
|
|
1385
|
+
padding: 0;
|
|
1386
|
+
background: none;
|
|
1387
|
+
border: none;
|
|
1388
|
+
cursor: pointer;
|
|
1389
|
+
color: inherit;
|
|
1390
|
+
font: inherit;
|
|
1391
|
+
letter-spacing: inherit;
|
|
1392
|
+
text-transform: inherit;
|
|
1393
|
+
text-shadow: inherit;
|
|
1394
|
+
text-align: left;
|
|
1395
|
+
|
|
1396
|
+
svg {
|
|
1397
|
+
width: 12px;
|
|
1398
|
+
height: 12px;
|
|
1399
|
+
flex-shrink: 0;
|
|
1400
|
+
transition: transform 0.15s;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
&[aria-expanded="true"] svg {
|
|
1404
|
+
transform: rotate(180deg);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1408
|
+
svg {
|
|
1409
|
+
transition: none;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
&:focus-visible {
|
|
1414
|
+
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
1415
|
+
outline-offset: 2px;
|
|
1416
|
+
}
|
|
1417
|
+
`;
|
|
1418
|
+
|
|
1419
|
+
const TitleButton__Text = styled.span`
|
|
1420
|
+
overflow: hidden;
|
|
1421
|
+
text-overflow: ellipsis;
|
|
1422
|
+
white-space: nowrap;
|
|
1423
|
+
`;
|
|
1424
|
+
|
|
1425
|
+
const StepButtons = styled.div`
|
|
1426
|
+
display: flex;
|
|
1427
|
+
align-items: center;
|
|
1428
|
+
gap: 4px;
|
|
1429
|
+
flex-shrink: 0;
|
|
1430
|
+
`;
|
|
1431
|
+
|
|
1432
|
+
const CameraMenu = styled.div`
|
|
1433
|
+
/* Portaled to document.body: fixed position (set inline from the trigger
|
|
1434
|
+
rect) keeps it clear of the tile's overflow clipping. */
|
|
1435
|
+
position: fixed;
|
|
1436
|
+
z-index: 1000;
|
|
1437
|
+
max-width: min(260px, calc(100vw - 16px));
|
|
1438
|
+
/* Cap the list so a long camera roster scrolls instead of spilling past
|
|
1439
|
+
the tile/viewport. 40vh keeps it sane on short windows; 300px on tall. */
|
|
1440
|
+
max-height: min(40vh, 300px);
|
|
1441
|
+
display: flex;
|
|
1442
|
+
flex-direction: column;
|
|
1443
|
+
background: rgba(0, 0, 0, 0.85);
|
|
1444
|
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
1445
|
+
border-radius: 4px;
|
|
1446
|
+
overflow-x: hidden;
|
|
1447
|
+
overflow-y: auto;
|
|
1448
|
+
`;
|
|
1449
|
+
|
|
1450
|
+
const CameraMenuItem = styled.button<{ $selected: boolean }>`
|
|
1451
|
+
display: block;
|
|
1452
|
+
width: 100%;
|
|
1453
|
+
flex-shrink: 0;
|
|
1454
|
+
padding: 6px 8px;
|
|
1455
|
+
text-align: left;
|
|
1456
|
+
background: ${(p) =>
|
|
1457
|
+
p.$selected
|
|
1458
|
+
? "var(--kerbcast-accent-wash, rgba(0, 255, 136, 0.15))"
|
|
1459
|
+
: "transparent"};
|
|
1460
|
+
border: none;
|
|
1461
|
+
cursor: pointer;
|
|
1462
|
+
color: #fff;
|
|
1463
|
+
font-size: 11px;
|
|
1464
|
+
letter-spacing: 0.04em;
|
|
1465
|
+
|
|
1466
|
+
@media (hover: hover) {
|
|
1467
|
+
&:hover {
|
|
1468
|
+
background: rgba(255, 255, 255, 0.15);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
&:focus-visible {
|
|
1473
|
+
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
1474
|
+
outline-offset: -2px;
|
|
1475
|
+
}
|
|
1476
|
+
`;
|
|
1477
|
+
|
|
1478
|
+
const TopMeta = styled.div`
|
|
1479
|
+
font-size: 11px;
|
|
1480
|
+
letter-spacing: 0.04em;
|
|
1481
|
+
color: rgba(255, 255, 255, 0.78);
|
|
1482
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
1483
|
+
`;
|
|
1484
|
+
|
|
1485
|
+
/* The quality picker: portaled to document.body like CameraMenu, hung from
|
|
1486
|
+
its action-bar trigger (fixed position set inline from the trigger rect)
|
|
1487
|
+
so tile clipping cannot cut it off and it survives the hover-revealed
|
|
1488
|
+
chrome fading while the pointer is over the menu. */
|
|
1489
|
+
const QualityMenu = styled.div`
|
|
1490
|
+
position: fixed;
|
|
1491
|
+
z-index: 1000;
|
|
1492
|
+
min-width: 140px;
|
|
1493
|
+
max-width: min(220px, calc(100vw - 16px));
|
|
1494
|
+
max-height: min(40vh, 220px);
|
|
1495
|
+
display: flex;
|
|
1496
|
+
flex-direction: column;
|
|
1497
|
+
background: rgba(0, 0, 0, 0.85);
|
|
1498
|
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
1499
|
+
border-radius: 4px;
|
|
1500
|
+
overflow-x: hidden;
|
|
1501
|
+
overflow-y: auto;
|
|
1502
|
+
`;
|
|
1503
|
+
|
|
1504
|
+
/* Effective-state footer of the quality menu: what the camera is actually
|
|
1505
|
+
rendering right now, with a "throttled" marker while the adaptive
|
|
1506
|
+
machinery holds it below the request. */
|
|
1507
|
+
const QualityMeta = styled.div`
|
|
1508
|
+
padding: 5px 8px;
|
|
1509
|
+
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
|
1510
|
+
font-size: 10px;
|
|
1511
|
+
letter-spacing: 0.04em;
|
|
1512
|
+
color: rgba(255, 255, 255, 0.65);
|
|
1513
|
+
`;
|
|
1514
|
+
|
|
1515
|
+
/* Throttled marker on the quality action button. */
|
|
1516
|
+
const ThrottledDot = styled.span`
|
|
1517
|
+
position: absolute;
|
|
1518
|
+
top: -3px;
|
|
1519
|
+
right: -3px;
|
|
1520
|
+
width: 7px;
|
|
1521
|
+
height: 7px;
|
|
1522
|
+
border-radius: 50%;
|
|
1523
|
+
background: #ffb347;
|
|
1524
|
+
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6);
|
|
1525
|
+
`;
|
|
1526
|
+
|
|
1527
|
+
/* Top-right cluster of overlay controls (custom actions + fullscreen/PiP). */
|
|
1528
|
+
const ActionBar = styled.div`
|
|
1529
|
+
position: absolute;
|
|
1530
|
+
top: 6px;
|
|
1531
|
+
right: 8px;
|
|
1532
|
+
z-index: 3;
|
|
1533
|
+
display: flex;
|
|
1534
|
+
align-items: center;
|
|
1535
|
+
gap: 4px;
|
|
1536
|
+
opacity: 0;
|
|
1537
|
+
pointer-events: none;
|
|
1538
|
+
transition: opacity 0.15s;
|
|
1539
|
+
|
|
1540
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1541
|
+
transition: none;
|
|
1542
|
+
}
|
|
1543
|
+
`;
|
|
1544
|
+
|
|
1545
|
+
/*
|
|
1546
|
+
* Outer frame. Fills the host container: the video is absolutely
|
|
1547
|
+
* positioned, so without explicit width/height the Stage collapses
|
|
1548
|
+
* to zero height.
|
|
1549
|
+
*/
|
|
1550
|
+
const Panel = styled.div`
|
|
1551
|
+
display: flex;
|
|
1552
|
+
flex-direction: column;
|
|
1553
|
+
width: 100%;
|
|
1554
|
+
height: 100%;
|
|
1555
|
+
box-sizing: border-box;
|
|
1556
|
+
background: #111;
|
|
1557
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
1558
|
+
border-radius: 4px;
|
|
1559
|
+
overflow: hidden;
|
|
1560
|
+
`;
|
|
1561
|
+
|
|
1562
|
+
const Stage = styled(Panel)<{ $pinned: boolean }>`
|
|
1563
|
+
padding: 0;
|
|
1564
|
+
gap: 0;
|
|
1565
|
+
position: relative;
|
|
1566
|
+
background: #000;
|
|
1567
|
+
align-items: center;
|
|
1568
|
+
justify-content: center;
|
|
1569
|
+
|
|
1570
|
+
&:hover ${TopOverlay},
|
|
1571
|
+
&:focus-within ${TopOverlay},
|
|
1572
|
+
&:hover ${ZoomControlsWrap},
|
|
1573
|
+
&:focus-within ${ZoomControlsWrap},
|
|
1574
|
+
&:hover ${PanControl},
|
|
1575
|
+
&:focus-within ${PanControl},
|
|
1576
|
+
&:hover ${ActionBar},
|
|
1577
|
+
&:focus-within ${ActionBar} {
|
|
1578
|
+
opacity: 1;
|
|
1579
|
+
}
|
|
1580
|
+
&:hover ${TopOverlay},
|
|
1581
|
+
&:focus-within ${TopOverlay},
|
|
1582
|
+
&:hover ${ActionBar},
|
|
1583
|
+
&:focus-within ${ActionBar} {
|
|
1584
|
+
pointer-events: auto;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
${(p) =>
|
|
1588
|
+
p.$pinned &&
|
|
1589
|
+
css`
|
|
1590
|
+
${TopOverlay} {
|
|
1591
|
+
opacity: 1;
|
|
1592
|
+
pointer-events: auto;
|
|
1593
|
+
}
|
|
1594
|
+
${ActionBar} {
|
|
1595
|
+
opacity: 1;
|
|
1596
|
+
pointer-events: auto;
|
|
1597
|
+
}
|
|
1598
|
+
${ZoomControlsWrap},
|
|
1599
|
+
${PanControl} {
|
|
1600
|
+
opacity: 1;
|
|
1601
|
+
}
|
|
1602
|
+
`}
|
|
1603
|
+
`;
|
|
1604
|
+
|
|
1605
|
+
const Empty = styled.div`
|
|
1606
|
+
color: #888;
|
|
1607
|
+
font-size: 13px;
|
|
1608
|
+
font-style: italic;
|
|
1609
|
+
padding: 1rem;
|
|
1610
|
+
text-align: center;
|
|
1611
|
+
`;
|
|
1612
|
+
|
|
1613
|
+
const OverlayIconButton = styled.button<{ $active?: boolean }>`
|
|
1614
|
+
position: relative;
|
|
1615
|
+
width: 24px;
|
|
1616
|
+
height: 24px;
|
|
1617
|
+
display: flex;
|
|
1618
|
+
align-items: center;
|
|
1619
|
+
justify-content: center;
|
|
1620
|
+
padding: 0;
|
|
1621
|
+
background: ${(p) =>
|
|
1622
|
+
p.$active
|
|
1623
|
+
? "var(--kerbcast-accent, #00ff88)"
|
|
1624
|
+
: "rgba(0, 0, 0, 0.5)"};
|
|
1625
|
+
border: 1px solid
|
|
1626
|
+
${(p) =>
|
|
1627
|
+
p.$active
|
|
1628
|
+
? "var(--kerbcast-accent, #00ff88)"
|
|
1629
|
+
: "rgba(255, 255, 255, 0.3)"};
|
|
1630
|
+
border-radius: 3px;
|
|
1631
|
+
color: ${(p) => (p.$active ? "#000" : "#fff")};
|
|
1632
|
+
cursor: pointer;
|
|
1633
|
+
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
|
1634
|
+
|
|
1635
|
+
@media (hover: hover) {
|
|
1636
|
+
&:hover {
|
|
1637
|
+
background: ${(p) =>
|
|
1638
|
+
p.$active
|
|
1639
|
+
? "var(--kerbcast-accent, #00ff88)"
|
|
1640
|
+
: "rgba(0, 0, 0, 0.7)"};
|
|
1641
|
+
border-color: rgba(255, 255, 255, 0.6);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
&:focus-visible {
|
|
1646
|
+
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
1647
|
+
outline-offset: 2px;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
&:disabled {
|
|
1651
|
+
opacity: 0.4;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1655
|
+
transition: none;
|
|
1656
|
+
}
|
|
1657
|
+
`;
|
|
1658
|
+
|
|
1659
|
+
const StyledVideo = styled.video`
|
|
1660
|
+
position: absolute;
|
|
1661
|
+
inset: 0;
|
|
1662
|
+
width: 100%;
|
|
1663
|
+
height: 100%;
|
|
1664
|
+
object-fit: contain;
|
|
1665
|
+
`;
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* Shown when the sidecar reports `lifecycle: "destroyed"`. The kerbcast SDK
|
|
1669
|
+
* keeps the camera's noise pipeline alive on the same `mediaStream`, so the
|
|
1670
|
+
* video behind this overlay shows live signal-loss static.
|
|
1671
|
+
*/
|
|
1672
|
+
const SignalLostOverlay = styled.div`
|
|
1673
|
+
position: absolute;
|
|
1674
|
+
inset: 0;
|
|
1675
|
+
display: flex;
|
|
1676
|
+
align-items: center;
|
|
1677
|
+
justify-content: center;
|
|
1678
|
+
background: rgba(0, 0, 0, 0.25);
|
|
1679
|
+
`;
|
|
1680
|
+
|
|
1681
|
+
const SignalLostText = styled.span<{ $animated: boolean }>`
|
|
1682
|
+
color: #ff4444;
|
|
1683
|
+
font-size: 1.1rem;
|
|
1684
|
+
font-weight: 700;
|
|
1685
|
+
letter-spacing: 0.15em;
|
|
1686
|
+
text-transform: uppercase;
|
|
1687
|
+
text-shadow:
|
|
1688
|
+
0 0 8px rgba(255, 68, 68, 0.7),
|
|
1689
|
+
0 1px 2px rgba(0, 0, 0, 0.9);
|
|
1690
|
+
|
|
1691
|
+
${(p) =>
|
|
1692
|
+
p.$animated &&
|
|
1693
|
+
`
|
|
1694
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
1695
|
+
animation: signal-lost-pulse 2s ease-in-out infinite;
|
|
1696
|
+
}
|
|
1697
|
+
`}
|
|
1698
|
+
|
|
1699
|
+
@keyframes signal-lost-pulse {
|
|
1700
|
+
0%,
|
|
1701
|
+
100% {
|
|
1702
|
+
opacity: 1;
|
|
1703
|
+
}
|
|
1704
|
+
50% {
|
|
1705
|
+
opacity: 0.6;
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
`;
|
|
1709
|
+
|
|
1710
|
+
/*
|
|
1711
|
+
* No-static stall presentation (`showStatic={false}`): a subtle dim over
|
|
1712
|
+
* the frozen last frame plus a corner badge, so a frozen frame is never
|
|
1713
|
+
* mistakable for a live one. Always visible (not hover chrome).
|
|
1714
|
+
*/
|
|
1715
|
+
const StaleScrim = styled.div`
|
|
1716
|
+
position: absolute;
|
|
1717
|
+
inset: 0;
|
|
1718
|
+
z-index: 1;
|
|
1719
|
+
background: rgba(0, 0, 0, 0.32);
|
|
1720
|
+
pointer-events: none;
|
|
1721
|
+
`;
|
|
1722
|
+
|
|
1723
|
+
const StaleBadge = styled.div`
|
|
1724
|
+
position: absolute;
|
|
1725
|
+
bottom: 8px;
|
|
1726
|
+
right: 8px;
|
|
1727
|
+
z-index: 2;
|
|
1728
|
+
display: flex;
|
|
1729
|
+
align-items: center;
|
|
1730
|
+
gap: 4px;
|
|
1731
|
+
padding: 2px 6px;
|
|
1732
|
+
background: rgba(0, 0, 0, 0.6);
|
|
1733
|
+
border: 1px solid rgba(255, 179, 71, 0.6);
|
|
1734
|
+
border-radius: 3px;
|
|
1735
|
+
color: #ffb347;
|
|
1736
|
+
font-size: 10px;
|
|
1737
|
+
font-weight: 600;
|
|
1738
|
+
letter-spacing: 0.12em;
|
|
1739
|
+
text-transform: uppercase;
|
|
1740
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
1741
|
+
pointer-events: none;
|
|
1742
|
+
`;
|
|
1743
|
+
|
|
1744
|
+
const FovSlider = styled.input`
|
|
1745
|
+
writing-mode: vertical-lr;
|
|
1746
|
+
width: 100%;
|
|
1747
|
+
height: 54px;
|
|
1748
|
+
margin: 0;
|
|
1749
|
+
padding: 3px 0;
|
|
1750
|
+
cursor: pointer;
|
|
1751
|
+
accent-color: #fff;
|
|
1752
|
+
|
|
1753
|
+
&:focus-visible {
|
|
1754
|
+
outline: 2px solid #fff;
|
|
1755
|
+
outline-offset: -2px;
|
|
1756
|
+
}
|
|
1757
|
+
`;
|