@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,1110 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { PanZoomController, QualityPreset } from "@ksp-gonogo/kerbcast";
3
+ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import styled, { css } from "styled-components";
6
+ import { buildCameraLabeler } from "./cameraLabels";
7
+ import { KerbcastProvider, useKerbcastClient } from "./context";
8
+ import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
9
+ import { useKerbcastStream } from "./hooks/useKerbcastStream";
10
+ import { isCameraDestroyed } from "./lifecycle";
11
+ // ---------------------------------------------------------------------------
12
+ // Tuning constants
13
+ // ---------------------------------------------------------------------------
14
+ const PAN_BALL_RADIUS = 15; // pixel deflection bound (full = rate 1)
15
+ const MENU_MAX_WIDTH = 260; // matches CameraMenu's CSS cap
16
+ const MENU_MAX_HEIGHT = 300; // matches CameraMenu's min(40vh, 300px) cap
17
+ const QUALITY_MENU_MAX_WIDTH = 220; // matches QualityMenu's CSS cap
18
+ const QUALITY_MENU_MAX_HEIGHT = 220; // matches QualityMenu's min(40vh, 220px) cap
19
+ const MENU_GAP = 4; // trigger-to-menu spacing
20
+ const MENU_EDGE = 8; // minimum inset from the viewport edge
21
+ /*
22
+ * Fixed-position style for a portaled menu, anchored to its trigger button.
23
+ * Opens downward by default; flips above the trigger when there is not
24
+ * enough room below but there is above, otherwise clamps to the viewport.
25
+ */
26
+ function computeMenuPosition(trigger, anchor) {
27
+ const rect = trigger.getBoundingClientRect();
28
+ const vw = window.innerWidth;
29
+ const vh = window.innerHeight;
30
+ const width = Math.min(anchor.maxWidth, vw - 2 * MENU_EDGE);
31
+ /* Inline offset from the aligned viewport edge, clamped so a full-width
32
+ menu still fits inside the opposite edge. */
33
+ const inset = Math.max(MENU_EDGE, vw - width - MENU_EDGE);
34
+ const inline = anchor.align === "start"
35
+ ? { left: Math.min(Math.max(rect.left, MENU_EDGE), inset) }
36
+ : { right: Math.min(Math.max(vw - rect.right, MENU_EDGE), inset) };
37
+ const menuH = Math.min(0.4 * vh, anchor.maxHeight);
38
+ const fitsBelow = rect.bottom + MENU_GAP + menuH <= vh - MENU_EDGE;
39
+ const fitsAbove = rect.top - MENU_GAP - menuH >= MENU_EDGE;
40
+ if (!fitsBelow && fitsAbove) {
41
+ return { ...inline, bottom: vh - rect.top + MENU_GAP };
42
+ }
43
+ const top = Math.min(rect.bottom + MENU_GAP, Math.max(MENU_EDGE, vh - MENU_EDGE - menuH));
44
+ return { ...inline, top };
45
+ }
46
+ /*
47
+ * Shared behaviour for a dropdown portaled to document.body: fixed position
48
+ * computed from the trigger's rect (so tile overflow cannot clip it),
49
+ * re-anchored on window resize/scroll while open, Escape to close with focus
50
+ * returned to the trigger, and portal-aware outside-pointer-down dismissal
51
+ * (menuRef points at the portaled menu, so clicks inside it stay "inside").
52
+ * One menu at a time falls out of the dismissal: opening another menu's
53
+ * trigger is an outside press for this one.
54
+ */
55
+ function usePortalMenu({ maxWidth, maxHeight, align }) {
56
+ const [open, setOpen] = useState(false);
57
+ const [position, setPosition] = useState(null);
58
+ const menuRef = useRef(null);
59
+ const triggerRef = useRef(null);
60
+ useEffect(() => {
61
+ if (!open) {
62
+ setPosition(null);
63
+ return;
64
+ }
65
+ const update = () => {
66
+ const trigger = triggerRef.current;
67
+ if (trigger) {
68
+ setPosition(computeMenuPosition(trigger, { maxWidth, maxHeight, align }));
69
+ }
70
+ };
71
+ update();
72
+ window.addEventListener("resize", update);
73
+ window.addEventListener("scroll", update, true);
74
+ return () => {
75
+ window.removeEventListener("resize", update);
76
+ window.removeEventListener("scroll", update, true);
77
+ };
78
+ }, [open, maxWidth, maxHeight, align]);
79
+ useEffect(() => {
80
+ if (!open)
81
+ return;
82
+ const onKeyDown = (e) => {
83
+ if (e.key === "Escape") {
84
+ e.stopPropagation();
85
+ setOpen(false);
86
+ triggerRef.current?.focus();
87
+ }
88
+ };
89
+ const onPointerDown = (e) => {
90
+ if (!menuRef.current?.contains(e.target) &&
91
+ !triggerRef.current?.contains(e.target)) {
92
+ setOpen(false);
93
+ }
94
+ };
95
+ document.addEventListener("keydown", onKeyDown);
96
+ document.addEventListener("pointerdown", onPointerDown);
97
+ return () => {
98
+ document.removeEventListener("keydown", onKeyDown);
99
+ document.removeEventListener("pointerdown", onPointerDown);
100
+ };
101
+ }, [open]);
102
+ const toggle = useCallback(() => setOpen((v) => !v), []);
103
+ /** Close after an item pick, returning focus to the trigger. */
104
+ const close = useCallback(() => {
105
+ setOpen(false);
106
+ triggerRef.current?.focus();
107
+ }, []);
108
+ return { open, toggle, close, position, menuRef, triggerRef };
109
+ }
110
+ /** Round n to the nearest even integer, minimum 2 (H.264 chroma requirement). */
111
+ function toEvenPx(n) {
112
+ return Math.max(2, Math.round(n / 2) * 2);
113
+ }
114
+ /*
115
+ * Viewer quality presets, in menu order. Scales mirror the sidecar's
116
+ * QualityPreset mapping (fractions of the camera's operator render size);
117
+ * the target-dims hint floors to even exactly like the plugin does.
118
+ */
119
+ const QUALITY_PRESETS = [
120
+ { preset: QualityPreset.Full, label: "Full", scale: 1.0 },
121
+ { preset: QualityPreset.ThreeQuarter, label: "3/4", scale: 0.75 },
122
+ { preset: QualityPreset.Half, label: "1/2", scale: 0.5 },
123
+ { preset: QualityPreset.Quarter, label: "1/4", scale: 0.25 },
124
+ ];
125
+ function presetDim(operatorDim, scale) {
126
+ const v = Math.trunc(operatorDim * scale) & ~1;
127
+ return v < 2 ? 2 : v;
128
+ }
129
+ function isFullscreenSupported() {
130
+ if (typeof document === "undefined")
131
+ return false;
132
+ const d = document;
133
+ return Boolean(d.fullscreenEnabled || d.webkitFullscreenEnabled);
134
+ }
135
+ function currentFullscreenElement() {
136
+ const d = document;
137
+ return d.fullscreenElement ?? d.webkitFullscreenElement ?? null;
138
+ }
139
+ function requestFullscreen(el) {
140
+ const e = el;
141
+ void (e.requestFullscreen?.() ?? e.webkitRequestFullscreen?.());
142
+ }
143
+ function exitFullscreen() {
144
+ const d = document;
145
+ void (d.exitFullscreen?.() ?? d.webkitExitFullscreen?.());
146
+ }
147
+ // ---------------------------------------------------------------------------
148
+ // Inner component (reads from context)
149
+ // ---------------------------------------------------------------------------
150
+ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, onDisplayedCameraChange, showDebugInfo = false, showStatic, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, actions, trailingActions, useStream, }, ref) {
151
+ const client = useKerbcastClient();
152
+ const cameras = useKerbcastCameras();
153
+ /*
154
+ * Reduced-motion auto mode: when `showStatic` prop is undefined, read and
155
+ * track `prefers-reduced-motion: reduce`. Reduced motion defaults to off
156
+ * (no animated noise); normal motion defaults to on.
157
+ */
158
+ const [reducedMotion, setReducedMotion] = useState(() => {
159
+ if (typeof window === "undefined")
160
+ return false;
161
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
162
+ });
163
+ useEffect(() => {
164
+ if (showStatic !== undefined)
165
+ return;
166
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
167
+ const handler = (e) => setReducedMotion(e.matches);
168
+ mq.addEventListener("change", handler);
169
+ return () => mq.removeEventListener("change", handler);
170
+ }, [showStatic]);
171
+ const effectiveShowStatic = showStatic === undefined ? !reducedMotion : showStatic;
172
+ // -------------------------------------------------------------------------
173
+ // Selection model (mirrors gonogo's CameraFeed)
174
+ //
175
+ // Resolution order:
176
+ // 1. Explicit pick, if still present in the list (destroyed or not).
177
+ // 2. Auto: latch the currently-displayed camera. Keep showing it even if
178
+ // destroyed, but only while no other live camera exists (destroyed
179
+ // tombstones never leave the list on their own, so a destroyed latch
180
+ // would otherwise never release). Once a live camera is available,
181
+ // release the latch and auto-pick it.
182
+ // 3. Fresh auto-pick: prefer the first live camera; fall back to first
183
+ // overall only when every camera is destroyed.
184
+ // -------------------------------------------------------------------------
185
+ const displayedRef = useRef(null);
186
+ const requestedStillPresent = requestedFlightId !== null &&
187
+ cameras.some((c) => c.flightId === requestedFlightId);
188
+ let flightId;
189
+ if (requestedStillPresent) {
190
+ flightId = requestedFlightId;
191
+ }
192
+ else {
193
+ const latched = displayedRef.current;
194
+ const latchedCamera = latched !== null ? cameras.find((c) => c.flightId === latched) : undefined;
195
+ const anyLive = cameras.some((c) => !isCameraDestroyed(c));
196
+ const latchedPresent = latchedCamera !== undefined &&
197
+ (!isCameraDestroyed(latchedCamera) || !anyLive);
198
+ flightId = latchedPresent
199
+ ? latched
200
+ : (cameras.find((c) => !isCameraDestroyed(c))?.flightId ??
201
+ cameras[0]?.flightId ??
202
+ null);
203
+ }
204
+ const camera = flightId !== null
205
+ ? (cameras.find((c) => c.flightId === flightId) ?? null)
206
+ : null;
207
+ // Latest onDisplayedCameraChange, held in a ref so the commit effect below
208
+ // fires only on a resolved-flightId change, not on every render when the
209
+ // consumer passes an inline callback.
210
+ const onDisplayedRef = useRef(onDisplayedCameraChange);
211
+ useEffect(() => {
212
+ onDisplayedRef.current = onDisplayedCameraChange;
213
+ });
214
+ // Commit on-screen camera for the auto-mode latch, and report which camera
215
+ // is actually displayed so consumers can label by it (auto-picks included).
216
+ useEffect(() => {
217
+ displayedRef.current = flightId;
218
+ onDisplayedRef.current?.(flightId);
219
+ }, [flightId]);
220
+ // `flightId` here is the RESOLVED id (auto-latch / fallback applied), so a
221
+ // consumer-injected hook never has to duplicate that resolution. The
222
+ // built-in hook is the default. See CameraStreamHook's rules-of-hooks note.
223
+ const resolveStream = useStream ?? useKerbcastStream;
224
+ const stream = resolveStream(flightId);
225
+ const videoRef = useRef(null);
226
+ // The feed frame; fullscreen targets this and ResizeObserver measures it.
227
+ const wrapRef = useRef(null);
228
+ useEffect(() => {
229
+ if (videoRef.current && stream) {
230
+ videoRef.current.srcObject = stream;
231
+ }
232
+ }, [stream]);
233
+ // -------------------------------------------------------------------------
234
+ // Stall presentation. The static is composited in-stream by the SDK's
235
+ // noise pipeline; this forwards the resolved setting to the camera handle
236
+ // and mirrors the handle's stall state for the no-static badge.
237
+ // -------------------------------------------------------------------------
238
+ useEffect(() => {
239
+ if (flightId === null)
240
+ return;
241
+ client.camera(flightId).setShowStatic(effectiveShowStatic);
242
+ }, [client, flightId, effectiveShowStatic]);
243
+ const [isStale, setIsStale] = useState(false);
244
+ useEffect(() => {
245
+ if (flightId === null) {
246
+ setIsStale(false);
247
+ return;
248
+ }
249
+ const cam = client.camera(flightId);
250
+ setIsStale(cam.stalled);
251
+ return cam.on("stall", setIsStale);
252
+ }, [client, flightId]);
253
+ // -------------------------------------------------------------------------
254
+ // Fullscreen + Picture-in-Picture (opt-in, feature-detected)
255
+ // -------------------------------------------------------------------------
256
+ const fullscreenAvailable = enableFullscreen && isFullscreenSupported();
257
+ const pipAvailable = enablePictureInPicture &&
258
+ typeof document !== "undefined" &&
259
+ document.pictureInPictureEnabled === true;
260
+ const [isFullscreen, setIsFullscreen] = useState(false);
261
+ const [isPip, setIsPip] = useState(false);
262
+ // Keep the fullscreen icon in sync however the user enters/exits (Esc, etc).
263
+ useEffect(() => {
264
+ if (!fullscreenAvailable)
265
+ return;
266
+ const sync = () => setIsFullscreen(currentFullscreenElement() === wrapRef.current);
267
+ document.addEventListener("fullscreenchange", sync);
268
+ document.addEventListener("webkitfullscreenchange", sync);
269
+ sync();
270
+ return () => {
271
+ document.removeEventListener("fullscreenchange", sync);
272
+ document.removeEventListener("webkitfullscreenchange", sync);
273
+ };
274
+ }, [fullscreenAvailable]);
275
+ // PiP listeners re-attach when the <video> mounts/remounts (flightId change).
276
+ useEffect(() => {
277
+ if (!pipAvailable)
278
+ return;
279
+ const v = videoRef.current;
280
+ if (!v)
281
+ return;
282
+ const onEnter = () => setIsPip(true);
283
+ const onLeave = () => setIsPip(false);
284
+ v.addEventListener("enterpictureinpicture", onEnter);
285
+ v.addEventListener("leavepictureinpicture", onLeave);
286
+ return () => {
287
+ v.removeEventListener("enterpictureinpicture", onEnter);
288
+ v.removeEventListener("leavepictureinpicture", onLeave);
289
+ };
290
+ }, [pipAvailable, flightId]);
291
+ const toggleFullscreen = useCallback(() => {
292
+ const el = wrapRef.current;
293
+ if (!el)
294
+ return;
295
+ if (currentFullscreenElement())
296
+ exitFullscreen();
297
+ else
298
+ requestFullscreen(el);
299
+ }, []);
300
+ const togglePip = useCallback(() => {
301
+ const v = videoRef.current;
302
+ if (!v)
303
+ return;
304
+ if (document.pictureInPictureElement) {
305
+ void document.exitPictureInPicture().catch(() => { });
306
+ }
307
+ else {
308
+ void v.requestPictureInPicture().catch(() => { });
309
+ }
310
+ }, []);
311
+ const isDestroyed = camera ? isCameraDestroyed(camera) : false;
312
+ const showPan = camera?.supportsPan && !isDestroyed;
313
+ const showZoom = camera?.supportsZoom && !isDestroyed;
314
+ const supportsPitch = !!camera && camera.panPitchMax - camera.panPitchMin > 0;
315
+ // -------------------------------------------------------------------------
316
+ // Camera selection callbacks (Next/Prev, picker)
317
+ // onSelectCamera is ONLY called on explicit user picks, never on auto-latch.
318
+ // -------------------------------------------------------------------------
319
+ const currentIndex = useMemo(() => flightId !== null
320
+ ? cameras.findIndex((c) => c.flightId === flightId)
321
+ : -1, [cameras, flightId]);
322
+ const stepCamera = useCallback((delta) => {
323
+ if (cameras.length === 0)
324
+ return;
325
+ const base = currentIndex >= 0 ? currentIndex : 0;
326
+ const next = (base + delta + cameras.length) % cameras.length;
327
+ const nextId = cameras[next]?.flightId;
328
+ if (nextId !== undefined)
329
+ onSelectCamera?.(nextId);
330
+ }, [cameras, currentIndex, onSelectCamera]);
331
+ // -------------------------------------------------------------------------
332
+ // PanZoomController -- one per displayed camera
333
+ // -------------------------------------------------------------------------
334
+ const controllerRef = useRef(null);
335
+ const [sliderFov, setSliderFov] = useState(60);
336
+ useEffect(() => {
337
+ if (flightId === null) {
338
+ controllerRef.current?.dispose();
339
+ controllerRef.current = null;
340
+ return;
341
+ }
342
+ const cam = client.camera(flightId);
343
+ const ctrl = new PanZoomController(cam);
344
+ controllerRef.current?.dispose();
345
+ controllerRef.current = ctrl;
346
+ const unsubSlider = ctrl.onSliderFov(setSliderFov);
347
+ // Seed the slider from the controller's initial (0) -- will be overwritten
348
+ // immediately by syncFromState in the camera-state effect below.
349
+ setSliderFov(ctrl.sliderFov);
350
+ return () => {
351
+ unsubSlider();
352
+ ctrl.stop();
353
+ ctrl.dispose();
354
+ controllerRef.current = null;
355
+ };
356
+ // eslint-disable-next-line react-hooks/exhaustive-deps
357
+ }, [client, flightId]);
358
+ // Sync controller from camera state on every state update.
359
+ useEffect(() => {
360
+ if (!camera || !controllerRef.current)
361
+ return;
362
+ controllerRef.current.syncFromState(camera);
363
+ }, [camera]);
364
+ // Stop pan/zoom when the control hides (signal lost / support dropped).
365
+ useEffect(() => {
366
+ if (!showPan) {
367
+ controllerRef.current?.setPanRate(0, 0);
368
+ setBallPos({ x: 0, y: 0 });
369
+ setBallActive(false);
370
+ }
371
+ }, [showPan]);
372
+ useEffect(() => {
373
+ if (!showZoom)
374
+ controllerRef.current?.setZoomRate(0);
375
+ }, [showZoom]);
376
+ // -------------------------------------------------------------------------
377
+ // Ball drag state (pixel math lives here)
378
+ // -------------------------------------------------------------------------
379
+ const ballStartRef = useRef({ x: 0, y: 0 });
380
+ const [ballPos, setBallPos] = useState({ x: 0, y: 0 });
381
+ const [ballActive, setBallActive] = useState(false);
382
+ const handleBallDown = useCallback((e) => {
383
+ if (flightId === null)
384
+ return;
385
+ e.currentTarget.setPointerCapture(e.pointerId);
386
+ ballStartRef.current = { x: e.clientX, y: e.clientY };
387
+ setBallActive(true);
388
+ controllerRef.current?.setBallDragging(true);
389
+ }, [flightId]);
390
+ const handleBallMove = useCallback((e) => {
391
+ if (!ballActive)
392
+ return;
393
+ let dx = e.clientX - ballStartRef.current.x;
394
+ let dy = supportsPitch ? e.clientY - ballStartRef.current.y : 0;
395
+ const mag = Math.hypot(dx, dy);
396
+ if (mag > PAN_BALL_RADIUS) {
397
+ const k = PAN_BALL_RADIUS / mag;
398
+ dx *= k;
399
+ dy *= k;
400
+ }
401
+ setBallPos({ x: dx, y: dy });
402
+ controllerRef.current?.setPanRate(dx / PAN_BALL_RADIUS, -dy / PAN_BALL_RADIUS);
403
+ }, [ballActive, supportsPitch]);
404
+ const handleBallUp = useCallback(() => {
405
+ setBallActive(false);
406
+ setBallPos({ x: 0, y: 0 });
407
+ controllerRef.current?.setBallDragging(false);
408
+ controllerRef.current?.setPanRate(0, 0);
409
+ }, []);
410
+ // -------------------------------------------------------------------------
411
+ // Handle API (forwardRef)
412
+ // -------------------------------------------------------------------------
413
+ useImperativeHandle(ref, () => ({
414
+ stepCamera,
415
+ setZoomRate(rate) {
416
+ if (!showZoom)
417
+ return;
418
+ controllerRef.current?.setZoomRate(rate);
419
+ },
420
+ setPanAxis(axis, value) {
421
+ if (!showPan)
422
+ return;
423
+ if (axis === "pitch" && !supportsPitch)
424
+ return;
425
+ controllerRef.current?.setPanAxis(axis, value);
426
+ },
427
+ nudgeZoom(deltaSign) {
428
+ if (!showZoom)
429
+ return;
430
+ controllerRef.current?.nudgeZoom(deltaSign);
431
+ },
432
+ nudgePan(yawSign, pitchSign) {
433
+ if (!showPan)
434
+ return;
435
+ controllerRef.current?.nudgePan(yawSign, pitchSign);
436
+ },
437
+ }), [stepCamera, showZoom, showPan, supportsPitch]);
438
+ // -------------------------------------------------------------------------
439
+ // Render-size feedback (ResizeObserver, 500 ms debounce, 16:9 crop)
440
+ // -------------------------------------------------------------------------
441
+ const resizeTimerRef = useRef(null);
442
+ useEffect(() => {
443
+ if (renderSize !== "auto" || flightId === null)
444
+ return;
445
+ const el = wrapRef.current;
446
+ if (!el)
447
+ return;
448
+ const cam = client.camera(flightId);
449
+ const observer = new ResizeObserver((entries) => {
450
+ const entry = entries[0];
451
+ if (!entry)
452
+ return;
453
+ const { width } = entry.contentRect;
454
+ if (resizeTimerRef.current !== null)
455
+ clearTimeout(resizeTimerRef.current);
456
+ resizeTimerRef.current = setTimeout(() => {
457
+ const w = toEvenPx(width);
458
+ const h = toEvenPx((width * 9) / 16);
459
+ void cam.setRenderSize(w, h);
460
+ }, 500);
461
+ });
462
+ observer.observe(el);
463
+ return () => {
464
+ observer.disconnect();
465
+ if (resizeTimerRef.current !== null)
466
+ clearTimeout(resizeTimerRef.current);
467
+ };
468
+ }, [client, flightId, renderSize]);
469
+ // -------------------------------------------------------------------------
470
+ // UI state
471
+ // -------------------------------------------------------------------------
472
+ const bitrateLabel = camera && camera.encoderBitrateBps > 0
473
+ ? ` · ${Math.round(camera.encoderBitrateBps / 1000)}kbps`
474
+ : "";
475
+ const adaptiveLabel = camera && camera.renderWidth < camera.operatorWidth ? " · adaptive" : "";
476
+ const hasCameras = cameras.length > 0;
477
+ const canStep = cameras.length > 1;
478
+ const menuId = useId();
479
+ const [chromePinned, setChromePinned] = useState(false);
480
+ const cameraMenu = usePortalMenu({
481
+ maxWidth: MENU_MAX_WIDTH,
482
+ maxHeight: MENU_MAX_HEIGHT,
483
+ align: "start",
484
+ });
485
+ const cameraLabel = useMemo(() => buildCameraLabeler(cameras), [cameras]);
486
+ const title = camera ? cameraLabel(camera) : "Camera Feed";
487
+ // -------------------------------------------------------------------------
488
+ // Viewer quality control (opt-in). A built-in action button + menu: Auto
489
+ // plus the resolution presets. Requested state = camera.viewerQuality
490
+ // (authoritative, broadcast by the sidecar so every UI agrees); effective
491
+ // state = renderWidth/Height; qualityLimitedBy marks adaptive throttling.
492
+ // -------------------------------------------------------------------------
493
+ const qualityAvailable = enableQualityControl && flightId !== null && camera !== null;
494
+ const qualityThrottled = Boolean(camera?.qualityLimitedBy);
495
+ const qualityMenuId = useId();
496
+ // Same portal/anchor/dismissal machinery as the camera menu, hung from
497
+ // the right edge of its action-bar trigger.
498
+ const qualityMenu = usePortalMenu({
499
+ maxWidth: QUALITY_MENU_MAX_WIDTH,
500
+ maxHeight: QUALITY_MENU_MAX_HEIGHT,
501
+ align: "end",
502
+ });
503
+ const closeQualityMenu = qualityMenu.close;
504
+ const selectQuality = useCallback((preset) => {
505
+ if (flightId === null)
506
+ return;
507
+ void client.camera(flightId).setQuality(preset);
508
+ closeQualityMenu();
509
+ }, [client, flightId, closeQualityMenu]);
510
+ const topOverlay = (_jsxs(TopOverlay, { children: [_jsxs(TitleRow, { children: [_jsx(TopTitle, { children: hasCameras ? (_jsxs(TitleButton, { ref: cameraMenu.triggerRef, type: "button", "aria-haspopup": "menu", "aria-expanded": cameraMenu.open, "aria-controls": menuId, onClick: cameraMenu.toggle, children: [_jsx(TitleButton__Text, { children: title }), _jsx(ChevronDownIcon, { "aria-hidden": "true" })] })) : (title) }), hasCameras && (_jsxs(StepButtons, { children: [_jsx(OverlayIconButton, { type: "button", "aria-label": "Previous camera", disabled: !canStep, onClick: () => stepCamera(-1), children: "\u2039" }), _jsx(OverlayIconButton, { type: "button", "aria-label": "Next camera", disabled: !canStep, onClick: () => stepCamera(1), children: "\u203A" })] }))] }), cameraMenu.open &&
511
+ hasCameras &&
512
+ cameraMenu.position &&
513
+ createPortal(_jsx(CameraMenu, { ref: cameraMenu.menuRef, id: menuId, role: "menu", "aria-label": "Camera", style: cameraMenu.position, children: cameras.map((c) => (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": c.flightId === flightId, "$selected": c.flightId === flightId, onClick: () => {
514
+ onSelectCamera?.(c.flightId);
515
+ cameraMenu.close();
516
+ }, children: [cameraLabel(c), " (", c.vesselName, ")", isCameraDestroyed(c) ? " - signal lost" : ""] }, c.flightId))) }), document.body), showDebugInfo &&
517
+ (camera ? (_jsxs(TopMeta, { children: [camera.vesselName, " \u00B7 ", camera.renderWidth, "\u00D7", camera.renderHeight, bitrateLabel, adaptiveLabel] })) : (_jsx(TopMeta, { children: "no cameras on this vessel" })))] }));
518
+ const renderAction = (a) => (_jsx(OverlayIconButton, { type: "button", "aria-label": a.label, "aria-pressed": a.active ?? undefined, title: a.label, "$active": a.active ?? false, onClick: a.onClick, children: a.icon }, a.id));
519
+ const builtInActions = flightId !== null && (pipAvailable || fullscreenAvailable || qualityAvailable);
520
+ const hasActionBar = (actions && actions.length > 0) ||
521
+ (trailingActions && trailingActions.length > 0) ||
522
+ builtInActions;
523
+ const actionBar = hasActionBar ? (_jsxs(ActionBar, { children: [actions?.map(renderAction), qualityAvailable && (_jsxs(OverlayIconButton, { ref: qualityMenu.triggerRef, type: "button", "aria-label": "Quality", "aria-haspopup": "menu", "aria-expanded": qualityMenu.open, "aria-controls": qualityMenuId, title: qualityThrottled
524
+ ? "Quality (throttled by adaptive performance)"
525
+ : "Quality", "$active": qualityMenu.open, onClick: qualityMenu.toggle, children: [_jsx(QualityIcon, {}), qualityThrottled && _jsx(ThrottledDot, { "aria-hidden": "true" })] })), flightId !== null && pipAvailable && (_jsx(OverlayIconButton, { type: "button", "aria-label": isPip ? "Exit picture in picture" : "Picture in picture", "aria-pressed": isPip, title: isPip ? "Exit picture in picture" : "Picture in picture", "$active": isPip, onClick: togglePip, children: _jsx(PictureInPictureIcon, {}) })), flightId !== null && fullscreenAvailable && (_jsx(OverlayIconButton, { type: "button", "aria-label": isFullscreen ? "Exit fullscreen" : "Enter fullscreen", "aria-pressed": isFullscreen, title: isFullscreen ? "Exit fullscreen" : "Enter fullscreen", "$active": isFullscreen, onClick: toggleFullscreen, children: isFullscreen ? _jsx(FullscreenExitIcon, {}) : _jsx(FullscreenEnterIcon, {}) })), trailingActions?.map(renderAction)] })) : null;
526
+ return (_jsx(Stage, { ref: wrapRef, "$pinned": chromePinned, children: flightId === null ? (_jsxs(_Fragment, { children: [_jsx(Empty, { children: emptyMessage }), topOverlay, actionBar] })) : (_jsxs(_Fragment, { children: [_jsx(StyledVideo, { ref: videoRef, autoPlay: true, playsInline: true, muted: true, controls: false, onClick: () => setChromePinned((v) => !v) }), topOverlay, actionBar, qualityAvailable &&
527
+ qualityMenu.open &&
528
+ qualityMenu.position &&
529
+ camera &&
530
+ createPortal(_jsxs(QualityMenu, { ref: qualityMenu.menuRef, id: qualityMenuId, role: "menu", "aria-label": "Quality", style: qualityMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality == null, "$selected": camera.viewerQuality == null, onClick: () => selectQuality(null), children: "Auto" }), QUALITY_PRESETS.map(({ preset, label, scale }) => (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality === preset, "$selected": camera.viewerQuality === preset, onClick: () => selectQuality(preset), children: [label, " (", presetDim(camera.operatorWidth, scale), "\u00D7", presetDim(camera.operatorHeight, scale), ")"] }, preset))), _jsxs(QualityMeta, { role: "status", children: ["now ", camera.renderWidth, "\u00D7", camera.renderHeight, qualityThrottled ? " · throttled" : ""] })] }), document.body), isDestroyed && (_jsx(SignalLostOverlay, { role: "status", "aria-label": "Signal lost", children: _jsx(SignalLostText, { "$animated": effectiveShowStatic, children: "SIGNAL LOST" }) })), !effectiveShowStatic && isStale && !isDestroyed && (_jsxs(_Fragment, { children: [_jsx(StaleScrim, { "aria-hidden": "true" }), _jsxs(StaleBadge, { role: "status", "aria-label": "Feed stale", children: [_jsx(StaleIcon, {}), "Stale"] })] })), showZoom && (_jsxs(ZoomControlsWrap, { children: [_jsx(ZoomButton, { type: "button", "aria-label": "Zoom in", "$pos": "top", onPointerDown: () => controllerRef.current?.setZoomRate(1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
531
+ if (e.detail === 0)
532
+ controllerRef.current?.nudgeZoom(-1);
533
+ }, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, onChange: (e) => {
534
+ const v = Number(e.target.value);
535
+ controllerRef.current?.fovSliderInput(v);
536
+ }, onPointerDown: () => {
537
+ controllerRef.current?.setFovSliderDragging(true);
538
+ }, onPointerUp: () => {
539
+ controllerRef.current?.setFovSliderDragging(false);
540
+ }, onPointerCancel: () => {
541
+ controllerRef.current?.setFovSliderDragging(false);
542
+ } }), _jsx(ZoomButton, { type: "button", "aria-label": "Zoom out", "$pos": "bottom", onPointerDown: () => controllerRef.current?.setZoomRate(-1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
543
+ if (e.detail === 0)
544
+ controllerRef.current?.nudgeZoom(1);
545
+ }, children: "\u2212" })] })), showPan && (_jsxs(PanControl, { role: "group", "aria-label": "Pan camera", children: [_jsx(PanArrow, { type: "button", "$dir": "up", "aria-label": "Pan up", disabled: !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, 1), children: "\u25B2" }), _jsx(PanArrow, { type: "button", "$dir": "down", "aria-label": "Pan down", disabled: !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, -1), children: "\u25BC" }), _jsx(PanArrow, { type: "button", "$dir": "left", "aria-label": "Pan left", onClick: () => controllerRef.current?.nudgePan(-1, 0), children: "\u25C0" }), _jsx(PanArrow, { type: "button", "$dir": "right", "aria-label": "Pan right", onClick: () => controllerRef.current?.nudgePan(1, 0), children: "\u25B6" }), _jsx(PanBall, { "aria-hidden": "true", title: "Drag to pan", onPointerDown: handleBallDown, onPointerMove: handleBallMove, onPointerUp: handleBallUp, onPointerCancel: handleBallUp, style: {
546
+ transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
547
+ } })] }))] })) }));
548
+ });
549
+ // ---------------------------------------------------------------------------
550
+ // Public CameraFeed - wraps inner in a nested provider when client prop given
551
+ // ---------------------------------------------------------------------------
552
+ /**
553
+ * Display a live kerbcast camera feed with pan/zoom controls and camera picker.
554
+ * Must be rendered inside a `KerbcastProvider` unless the `client` prop is
555
+ * provided (which creates an implicit inner provider for this feed only).
556
+ */
557
+ export const CameraFeed = forwardRef(function CameraFeed({ client, ...rest }, ref) {
558
+ if (client) {
559
+ return (_jsx(KerbcastProvider, { client: client, children: _jsx(CameraFeedInner, { ref: ref, ...rest }) }));
560
+ }
561
+ return _jsx(CameraFeedInner, { ref: ref, ...rest });
562
+ });
563
+ // ---------------------------------------------------------------------------
564
+ // Inline UI primitives (replacing @gonogo/ui dependencies)
565
+ // ---------------------------------------------------------------------------
566
+ function ChevronDownIcon(props) {
567
+ return (_jsx("svg", { viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": "true", ...props, children: _jsx("path", { d: "M4 6l4 4 4-4", stroke: "currentColor", strokeWidth: 1.5, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" }) }));
568
+ }
569
+ const iconProps = {
570
+ viewBox: "0 0 16 16",
571
+ width: 14,
572
+ height: 14,
573
+ fill: "none",
574
+ stroke: "currentColor",
575
+ strokeWidth: 1.6,
576
+ strokeLinecap: "round",
577
+ strokeLinejoin: "round",
578
+ "aria-hidden": true,
579
+ };
580
+ function FullscreenEnterIcon() {
581
+ return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M2 6V2.5h3.5M14 6V2.5h-3.5M2 10v3.5h3.5M14 10v3.5h-3.5" }) }));
582
+ }
583
+ function FullscreenExitIcon() {
584
+ return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M5.5 2v3.5H2M10.5 2v3.5H14M5.5 14v-3.5H2M10.5 14v-3.5H14" }) }));
585
+ }
586
+ /* Quality control: three horizontal slider rails with offset knobs. */
587
+ function QualityIcon() {
588
+ return (_jsxs("svg", { ...iconProps, children: [_jsx("path", { d: "M2 4.5h12M2 8h12M2 11.5h12" }), _jsx("circle", { cx: "10.5", cy: "4.5", r: "1.6", fill: "currentColor", stroke: "none" }), _jsx("circle", { cx: "5.5", cy: "8", r: "1.6", fill: "currentColor", stroke: "none" }), _jsx("circle", { cx: "8.5", cy: "11.5", r: "1.6", fill: "currentColor", stroke: "none" })] }));
589
+ }
590
+ /* Stale badge: fading signal bars (tallest dimmed). */
591
+ function StaleIcon() {
592
+ return (_jsxs("svg", { viewBox: "0 0 16 16", width: 10, height: 10, fill: "currentColor", stroke: "none", "aria-hidden": "true", children: [_jsx("rect", { x: "2", y: "9", width: "2.5", height: "5" }), _jsx("rect", { x: "6.5", y: "6", width: "2.5", height: "8" }), _jsx("rect", { x: "11", y: "3", width: "2.5", height: "11", opacity: "0.35" })] }));
593
+ }
594
+ function PictureInPictureIcon() {
595
+ return (_jsxs("svg", { ...iconProps, children: [_jsx("rect", { x: "2", y: "3", width: "12", height: "10", rx: "1" }), _jsx("rect", { x: "8", y: "8", width: "5", height: "4", rx: "0.5", fill: "currentColor", stroke: "none" })] }));
596
+ }
597
+ // ---------------------------------------------------------------------------
598
+ // Styled components
599
+ // ---------------------------------------------------------------------------
600
+ const PanControl = styled.div `
601
+ position: absolute;
602
+ bottom: 10px;
603
+ right: 10px;
604
+ width: 52px;
605
+ height: 52px;
606
+ opacity: 0;
607
+ transition: opacity 0.15s;
608
+ touch-action: none;
609
+
610
+ @media (prefers-reduced-motion: reduce) {
611
+ transition: none;
612
+ }
613
+ `;
614
+ const PanArrow = styled.button `
615
+ position: absolute;
616
+ width: 16px;
617
+ height: 16px;
618
+ display: flex;
619
+ align-items: center;
620
+ justify-content: center;
621
+ padding: 0;
622
+ font-size: 11px;
623
+ line-height: 1;
624
+ color: #fff;
625
+ opacity: 0.5;
626
+ background: none;
627
+ border: none;
628
+ cursor: pointer;
629
+ touch-action: none;
630
+ text-shadow:
631
+ 0 0 3px rgba(0, 0, 0, 0.9),
632
+ 0 1px 2px rgba(0, 0, 0, 0.8);
633
+
634
+ ${(p) => p.$dir === "up"
635
+ ? "top: 0; left: 50%; transform: translateX(-50%);"
636
+ : p.$dir === "down"
637
+ ? "bottom: 0; left: 50%; transform: translateX(-50%);"
638
+ : p.$dir === "left"
639
+ ? "left: 0; top: 50%; transform: translateY(-50%);"
640
+ : "right: 0; top: 50%; transform: translateY(-50%);"}
641
+
642
+ @media (hover: hover) {
643
+ &:hover:not(:disabled) {
644
+ opacity: 1;
645
+ color: var(--kerbcast-accent, #00ff88);
646
+ }
647
+ }
648
+ &:disabled {
649
+ opacity: 0.3;
650
+ cursor: default;
651
+ }
652
+ &:focus-visible {
653
+ outline: 2px solid var(--kerbcast-accent, #00ff88);
654
+ outline-offset: 2px;
655
+ }
656
+ `;
657
+ const PanBall = styled.div `
658
+ position: absolute;
659
+ top: 50%;
660
+ left: 50%;
661
+ width: 12px;
662
+ height: 12px;
663
+ margin: -6px 0 0 -6px;
664
+ border-radius: 50%;
665
+ background: radial-gradient(circle at 35% 30%, #ffffff, #d6dbe1);
666
+ box-shadow:
667
+ 0 0 0 1px rgba(0, 0, 0, 0.5),
668
+ 0 0 4px rgba(255, 255, 255, 0.4);
669
+ cursor: grab;
670
+ touch-action: none;
671
+
672
+ &:active {
673
+ cursor: grabbing;
674
+ }
675
+ `;
676
+ const ZoomControlsWrap = styled.div `
677
+ position: absolute;
678
+ bottom: 8px;
679
+ left: 8px;
680
+ width: 30px;
681
+ display: flex;
682
+ flex-direction: column;
683
+ align-items: stretch;
684
+ background: rgba(0, 0, 0, 0.6);
685
+ border: 1px solid rgba(255, 255, 255, 0.5);
686
+ opacity: 0;
687
+ transition: opacity 0.15s;
688
+
689
+ @media (prefers-reduced-motion: reduce) {
690
+ transition: none;
691
+ }
692
+ `;
693
+ const ZoomButton = styled.button `
694
+ width: 100%;
695
+ height: 26px;
696
+ display: flex;
697
+ align-items: center;
698
+ justify-content: center;
699
+ padding: 0;
700
+ background: transparent;
701
+ border: none;
702
+ border-radius: 0;
703
+ color: #fff;
704
+ font-size: 1rem;
705
+ cursor: pointer;
706
+ ${(p) => p.$pos === "top"
707
+ ? "border-bottom: 1px solid rgba(255, 255, 255, 0.3);"
708
+ : "border-top: 1px solid rgba(255, 255, 255, 0.3);"}
709
+
710
+ @media (hover: hover) {
711
+ &:hover {
712
+ color: #fff;
713
+ background: rgba(255, 255, 255, 0.15);
714
+ }
715
+ }
716
+
717
+ &:focus-visible {
718
+ outline: 2px solid #fff;
719
+ outline-offset: -2px;
720
+ }
721
+ `;
722
+ const TopOverlay = styled.div `
723
+ position: absolute;
724
+ top: 0;
725
+ left: 0;
726
+ right: 0;
727
+ z-index: 2;
728
+ display: flex;
729
+ flex-direction: column;
730
+ gap: 3px;
731
+ padding: 6px 8px 14px;
732
+ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.78), rgba(0, 0, 0, 0));
733
+ opacity: 0;
734
+ pointer-events: none;
735
+ transition: opacity 0.15s;
736
+
737
+ @media (prefers-reduced-motion: reduce) {
738
+ transition: none;
739
+ }
740
+ `;
741
+ const TitleRow = styled.div `
742
+ display: flex;
743
+ align-items: flex-start;
744
+ justify-content: space-between;
745
+ gap: 8px;
746
+ `;
747
+ const TopTitle = styled.h3 `
748
+ margin: 0;
749
+ min-width: 0;
750
+ font-size: var(--font-size-xs, 11px);
751
+ font-weight: 600;
752
+ letter-spacing: 0.12em;
753
+ text-transform: uppercase;
754
+ color: #fff;
755
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
756
+ `;
757
+ const TitleButton = styled.button `
758
+ display: inline-flex;
759
+ align-items: center;
760
+ gap: 4px;
761
+ max-width: 100%;
762
+ margin: 0;
763
+ padding: 0;
764
+ background: none;
765
+ border: none;
766
+ cursor: pointer;
767
+ color: inherit;
768
+ font: inherit;
769
+ letter-spacing: inherit;
770
+ text-transform: inherit;
771
+ text-shadow: inherit;
772
+ text-align: left;
773
+
774
+ svg {
775
+ width: 12px;
776
+ height: 12px;
777
+ flex-shrink: 0;
778
+ transition: transform 0.15s;
779
+ }
780
+
781
+ &[aria-expanded="true"] svg {
782
+ transform: rotate(180deg);
783
+ }
784
+
785
+ @media (prefers-reduced-motion: reduce) {
786
+ svg {
787
+ transition: none;
788
+ }
789
+ }
790
+
791
+ &:focus-visible {
792
+ outline: 2px solid var(--kerbcast-accent, #00ff88);
793
+ outline-offset: 2px;
794
+ }
795
+ `;
796
+ const TitleButton__Text = styled.span `
797
+ overflow: hidden;
798
+ text-overflow: ellipsis;
799
+ white-space: nowrap;
800
+ `;
801
+ const StepButtons = styled.div `
802
+ display: flex;
803
+ align-items: center;
804
+ gap: 4px;
805
+ flex-shrink: 0;
806
+ `;
807
+ const CameraMenu = styled.div `
808
+ /* Portaled to document.body: fixed position (set inline from the trigger
809
+ rect) keeps it clear of the tile's overflow clipping. */
810
+ position: fixed;
811
+ z-index: 1000;
812
+ max-width: min(260px, calc(100vw - 16px));
813
+ /* Cap the list so a long camera roster scrolls instead of spilling past
814
+ the tile/viewport. 40vh keeps it sane on short windows; 300px on tall. */
815
+ max-height: min(40vh, 300px);
816
+ display: flex;
817
+ flex-direction: column;
818
+ background: rgba(0, 0, 0, 0.85);
819
+ border: 1px solid rgba(255, 255, 255, 0.3);
820
+ border-radius: 4px;
821
+ overflow-x: hidden;
822
+ overflow-y: auto;
823
+ `;
824
+ const CameraMenuItem = styled.button `
825
+ display: block;
826
+ width: 100%;
827
+ flex-shrink: 0;
828
+ padding: 6px 8px;
829
+ text-align: left;
830
+ background: ${(p) => p.$selected
831
+ ? "var(--kerbcast-accent-wash, rgba(0, 255, 136, 0.15))"
832
+ : "transparent"};
833
+ border: none;
834
+ cursor: pointer;
835
+ color: #fff;
836
+ font-size: 11px;
837
+ letter-spacing: 0.04em;
838
+
839
+ @media (hover: hover) {
840
+ &:hover {
841
+ background: rgba(255, 255, 255, 0.15);
842
+ }
843
+ }
844
+
845
+ &:focus-visible {
846
+ outline: 2px solid var(--kerbcast-accent, #00ff88);
847
+ outline-offset: -2px;
848
+ }
849
+ `;
850
+ const TopMeta = styled.div `
851
+ font-size: 11px;
852
+ letter-spacing: 0.04em;
853
+ color: rgba(255, 255, 255, 0.78);
854
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
855
+ `;
856
+ /* The quality picker: portaled to document.body like CameraMenu, hung from
857
+ its action-bar trigger (fixed position set inline from the trigger rect)
858
+ so tile clipping cannot cut it off and it survives the hover-revealed
859
+ chrome fading while the pointer is over the menu. */
860
+ const QualityMenu = styled.div `
861
+ position: fixed;
862
+ z-index: 1000;
863
+ min-width: 140px;
864
+ max-width: min(220px, calc(100vw - 16px));
865
+ max-height: min(40vh, 220px);
866
+ display: flex;
867
+ flex-direction: column;
868
+ background: rgba(0, 0, 0, 0.85);
869
+ border: 1px solid rgba(255, 255, 255, 0.3);
870
+ border-radius: 4px;
871
+ overflow-x: hidden;
872
+ overflow-y: auto;
873
+ `;
874
+ /* Effective-state footer of the quality menu: what the camera is actually
875
+ rendering right now, with a "throttled" marker while the adaptive
876
+ machinery holds it below the request. */
877
+ const QualityMeta = styled.div `
878
+ padding: 5px 8px;
879
+ border-top: 1px solid rgba(255, 255, 255, 0.2);
880
+ font-size: 10px;
881
+ letter-spacing: 0.04em;
882
+ color: rgba(255, 255, 255, 0.65);
883
+ `;
884
+ /* Throttled marker on the quality action button. */
885
+ const ThrottledDot = styled.span `
886
+ position: absolute;
887
+ top: -3px;
888
+ right: -3px;
889
+ width: 7px;
890
+ height: 7px;
891
+ border-radius: 50%;
892
+ background: #ffb347;
893
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6);
894
+ `;
895
+ /* Top-right cluster of overlay controls (custom actions + fullscreen/PiP). */
896
+ const ActionBar = styled.div `
897
+ position: absolute;
898
+ top: 6px;
899
+ right: 8px;
900
+ z-index: 3;
901
+ display: flex;
902
+ align-items: center;
903
+ gap: 4px;
904
+ opacity: 0;
905
+ pointer-events: none;
906
+ transition: opacity 0.15s;
907
+
908
+ @media (prefers-reduced-motion: reduce) {
909
+ transition: none;
910
+ }
911
+ `;
912
+ /*
913
+ * Outer frame. Fills the host container: the video is absolutely
914
+ * positioned, so without explicit width/height the Stage collapses
915
+ * to zero height.
916
+ */
917
+ const Panel = styled.div `
918
+ display: flex;
919
+ flex-direction: column;
920
+ width: 100%;
921
+ height: 100%;
922
+ box-sizing: border-box;
923
+ background: #111;
924
+ border: 1px solid rgba(255, 255, 255, 0.1);
925
+ border-radius: 4px;
926
+ overflow: hidden;
927
+ `;
928
+ const Stage = styled(Panel) `
929
+ padding: 0;
930
+ gap: 0;
931
+ position: relative;
932
+ background: #000;
933
+ align-items: center;
934
+ justify-content: center;
935
+
936
+ &:hover ${TopOverlay},
937
+ &:focus-within ${TopOverlay},
938
+ &:hover ${ZoomControlsWrap},
939
+ &:focus-within ${ZoomControlsWrap},
940
+ &:hover ${PanControl},
941
+ &:focus-within ${PanControl},
942
+ &:hover ${ActionBar},
943
+ &:focus-within ${ActionBar} {
944
+ opacity: 1;
945
+ }
946
+ &:hover ${TopOverlay},
947
+ &:focus-within ${TopOverlay},
948
+ &:hover ${ActionBar},
949
+ &:focus-within ${ActionBar} {
950
+ pointer-events: auto;
951
+ }
952
+
953
+ ${(p) => p.$pinned &&
954
+ css `
955
+ ${TopOverlay} {
956
+ opacity: 1;
957
+ pointer-events: auto;
958
+ }
959
+ ${ActionBar} {
960
+ opacity: 1;
961
+ pointer-events: auto;
962
+ }
963
+ ${ZoomControlsWrap},
964
+ ${PanControl} {
965
+ opacity: 1;
966
+ }
967
+ `}
968
+ `;
969
+ const Empty = styled.div `
970
+ color: #888;
971
+ font-size: 13px;
972
+ font-style: italic;
973
+ padding: 1rem;
974
+ text-align: center;
975
+ `;
976
+ const OverlayIconButton = styled.button `
977
+ position: relative;
978
+ width: 24px;
979
+ height: 24px;
980
+ display: flex;
981
+ align-items: center;
982
+ justify-content: center;
983
+ padding: 0;
984
+ background: ${(p) => p.$active
985
+ ? "var(--kerbcast-accent, #00ff88)"
986
+ : "rgba(0, 0, 0, 0.5)"};
987
+ border: 1px solid
988
+ ${(p) => p.$active
989
+ ? "var(--kerbcast-accent, #00ff88)"
990
+ : "rgba(255, 255, 255, 0.3)"};
991
+ border-radius: 3px;
992
+ color: ${(p) => (p.$active ? "#000" : "#fff")};
993
+ cursor: pointer;
994
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
995
+
996
+ @media (hover: hover) {
997
+ &:hover {
998
+ background: ${(p) => p.$active
999
+ ? "var(--kerbcast-accent, #00ff88)"
1000
+ : "rgba(0, 0, 0, 0.7)"};
1001
+ border-color: rgba(255, 255, 255, 0.6);
1002
+ }
1003
+ }
1004
+
1005
+ &:focus-visible {
1006
+ outline: 2px solid var(--kerbcast-accent, #00ff88);
1007
+ outline-offset: 2px;
1008
+ }
1009
+
1010
+ &:disabled {
1011
+ opacity: 0.4;
1012
+ }
1013
+
1014
+ @media (prefers-reduced-motion: reduce) {
1015
+ transition: none;
1016
+ }
1017
+ `;
1018
+ const StyledVideo = styled.video `
1019
+ position: absolute;
1020
+ inset: 0;
1021
+ width: 100%;
1022
+ height: 100%;
1023
+ object-fit: contain;
1024
+ `;
1025
+ /**
1026
+ * Shown when the sidecar reports `lifecycle: "destroyed"`. The kerbcast SDK
1027
+ * keeps the camera's noise pipeline alive on the same `mediaStream`, so the
1028
+ * video behind this overlay shows live signal-loss static.
1029
+ */
1030
+ const SignalLostOverlay = styled.div `
1031
+ position: absolute;
1032
+ inset: 0;
1033
+ display: flex;
1034
+ align-items: center;
1035
+ justify-content: center;
1036
+ background: rgba(0, 0, 0, 0.25);
1037
+ `;
1038
+ const SignalLostText = styled.span `
1039
+ color: #ff4444;
1040
+ font-size: 1.1rem;
1041
+ font-weight: 700;
1042
+ letter-spacing: 0.15em;
1043
+ text-transform: uppercase;
1044
+ text-shadow:
1045
+ 0 0 8px rgba(255, 68, 68, 0.7),
1046
+ 0 1px 2px rgba(0, 0, 0, 0.9);
1047
+
1048
+ ${(p) => p.$animated &&
1049
+ `
1050
+ @media (prefers-reduced-motion: no-preference) {
1051
+ animation: signal-lost-pulse 2s ease-in-out infinite;
1052
+ }
1053
+ `}
1054
+
1055
+ @keyframes signal-lost-pulse {
1056
+ 0%,
1057
+ 100% {
1058
+ opacity: 1;
1059
+ }
1060
+ 50% {
1061
+ opacity: 0.6;
1062
+ }
1063
+ }
1064
+ `;
1065
+ /*
1066
+ * No-static stall presentation (`showStatic={false}`): a subtle dim over
1067
+ * the frozen last frame plus a corner badge, so a frozen frame is never
1068
+ * mistakable for a live one. Always visible (not hover chrome).
1069
+ */
1070
+ const StaleScrim = styled.div `
1071
+ position: absolute;
1072
+ inset: 0;
1073
+ z-index: 1;
1074
+ background: rgba(0, 0, 0, 0.32);
1075
+ pointer-events: none;
1076
+ `;
1077
+ const StaleBadge = styled.div `
1078
+ position: absolute;
1079
+ bottom: 8px;
1080
+ right: 8px;
1081
+ z-index: 2;
1082
+ display: flex;
1083
+ align-items: center;
1084
+ gap: 4px;
1085
+ padding: 2px 6px;
1086
+ background: rgba(0, 0, 0, 0.6);
1087
+ border: 1px solid rgba(255, 179, 71, 0.6);
1088
+ border-radius: 3px;
1089
+ color: #ffb347;
1090
+ font-size: 10px;
1091
+ font-weight: 600;
1092
+ letter-spacing: 0.12em;
1093
+ text-transform: uppercase;
1094
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
1095
+ pointer-events: none;
1096
+ `;
1097
+ const FovSlider = styled.input `
1098
+ writing-mode: vertical-lr;
1099
+ width: 100%;
1100
+ height: 54px;
1101
+ margin: 0;
1102
+ padding: 3px 0;
1103
+ cursor: pointer;
1104
+ accent-color: #fff;
1105
+
1106
+ &:focus-visible {
1107
+ outline: 2px solid #fff;
1108
+ outline-offset: -2px;
1109
+ }
1110
+ `;