@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.
@@ -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
+ &#8249;
788
+ </OverlayIconButton>
789
+ <OverlayIconButton
790
+ type="button"
791
+ aria-label="Next camera"
792
+ disabled={!canStep}
793
+ onClick={() => stepCamera(1)}
794
+ >
795
+ &#8250;
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
+ &#8722;
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
+ &#9650;
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
+ &#9660;
1065
+ </PanArrow>
1066
+ <PanArrow
1067
+ type="button"
1068
+ $dir="left"
1069
+ aria-label="Pan left"
1070
+ onClick={() => controllerRef.current?.nudgePan(-1, 0)}
1071
+ >
1072
+ &#9664;
1073
+ </PanArrow>
1074
+ <PanArrow
1075
+ type="button"
1076
+ $dir="right"
1077
+ aria-label="Pan right"
1078
+ onClick={() => controllerRef.current?.nudgePan(1, 0)}
1079
+ >
1080
+ &#9654;
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
+ `;