@ksp-gonogo/kerbcast-react 1.6.3 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +19 -0
  3. package/dist/CameraFeed.d.ts +63 -19
  4. package/dist/CameraFeed.js +329 -202
  5. package/dist/FeedActionBar.d.ts +133 -0
  6. package/dist/FeedActionBar.js +225 -0
  7. package/dist/KerbalFaceFeed.d.ts +82 -0
  8. package/dist/KerbalFaceFeed.js +111 -0
  9. package/dist/RecordingsTray.d.ts +37 -0
  10. package/dist/RecordingsTray.js +279 -0
  11. package/dist/context.d.ts +27 -1
  12. package/dist/context.js +68 -2
  13. package/dist/hooks/useRecordings.d.ts +35 -0
  14. package/dist/hooks/useRecordings.js +33 -0
  15. package/dist/hooks/useReportDisplaySize.d.ts +23 -0
  16. package/dist/hooks/useReportDisplaySize.js +71 -0
  17. package/dist/index.d.ts +12 -2
  18. package/dist/index.js +13 -1
  19. package/dist/menuPositioning.d.ts +20 -0
  20. package/dist/menuPositioning.js +98 -0
  21. package/dist/timing.d.ts +4 -0
  22. package/dist/timing.js +19 -0
  23. package/package.json +2 -2
  24. package/src/CameraFeed.test.tsx +719 -10
  25. package/src/CameraFeed.tsx +586 -334
  26. package/src/FeedActionBar.test.ts +196 -0
  27. package/src/FeedActionBar.test.tsx +215 -0
  28. package/src/FeedActionBar.tsx +391 -0
  29. package/src/KerbalFaceFeed.test.tsx +203 -0
  30. package/src/KerbalFaceFeed.tsx +214 -0
  31. package/src/RecordingsTray.test.tsx +288 -0
  32. package/src/RecordingsTray.tsx +475 -0
  33. package/src/context.test.tsx +94 -0
  34. package/src/context.tsx +98 -1
  35. package/src/hooks/useRecordings.sharedState.test.tsx +117 -0
  36. package/src/hooks/useRecordings.test.tsx +217 -0
  37. package/src/hooks/useRecordings.ts +110 -0
  38. package/src/hooks/useReportDisplaySize.test.tsx +166 -0
  39. package/src/hooks/useReportDisplaySize.ts +91 -0
  40. package/src/index.ts +27 -1
  41. package/src/menuPositioning.ts +126 -0
  42. package/src/timing.test.ts +34 -0
  43. package/src/timing.ts +21 -0
@@ -1,15 +1,20 @@
1
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";
2
+ import { PanZoomController, QualityPreset, TrackMode } from "@ksp-gonogo/kerbcast";
3
+ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useReducer, useRef, useState, } from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import styled, { css } from "styled-components";
6
6
  import { buildCameraLabeler } from "./cameraLabels";
7
7
  import { KerbcastProvider, useKerbcastClient } from "./context";
8
+ import { ActionBarRow, FeedActionBar, OverlayIconButton, feedActionToEntry, } from "./FeedActionBar";
8
9
  import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
9
10
  import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
10
11
  import { useKerbcastStream } from "./hooks/useKerbcastStream";
12
+ import { useRecordings } from "./hooks/useRecordings";
13
+ import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
11
14
  import { StandbyIcon } from "./StandbyIcon";
12
15
  import { isCameraDestroyed } from "./lifecycle";
16
+ import { usePortalMenu } from "./menuPositioning";
17
+ import { formatElapsed, nowMs } from "./timing";
13
18
  // ---------------------------------------------------------------------------
14
19
  // Tuning constants
15
20
  // ---------------------------------------------------------------------------
@@ -18,101 +23,6 @@ const MENU_MAX_WIDTH = 260; // matches CameraMenu's CSS cap
18
23
  const MENU_MAX_HEIGHT = 300; // matches CameraMenu's min(40vh, 300px) cap
19
24
  const QUALITY_MENU_MAX_WIDTH = 220; // matches QualityMenu's CSS cap
20
25
  const QUALITY_MENU_MAX_HEIGHT = 220; // matches QualityMenu's min(40vh, 220px) cap
21
- const MENU_GAP = 4; // trigger-to-menu spacing
22
- const MENU_EDGE = 8; // minimum inset from the viewport edge
23
- /*
24
- * Fixed-position style for a portaled menu, anchored to its trigger button.
25
- * Opens downward by default; flips above the trigger when there is not
26
- * enough room below but there is above, otherwise clamps to the viewport.
27
- */
28
- function computeMenuPosition(trigger, anchor) {
29
- const rect = trigger.getBoundingClientRect();
30
- const vw = window.innerWidth;
31
- const vh = window.innerHeight;
32
- const width = Math.min(anchor.maxWidth, vw - 2 * MENU_EDGE);
33
- /* Inline offset from the aligned viewport edge, clamped so a full-width
34
- menu still fits inside the opposite edge. */
35
- const inset = Math.max(MENU_EDGE, vw - width - MENU_EDGE);
36
- const inline = anchor.align === "start"
37
- ? { left: Math.min(Math.max(rect.left, MENU_EDGE), inset) }
38
- : { right: Math.min(Math.max(vw - rect.right, MENU_EDGE), inset) };
39
- const menuH = Math.min(0.4 * vh, anchor.maxHeight);
40
- const fitsBelow = rect.bottom + MENU_GAP + menuH <= vh - MENU_EDGE;
41
- const fitsAbove = rect.top - MENU_GAP - menuH >= MENU_EDGE;
42
- if (!fitsBelow && fitsAbove) {
43
- return { ...inline, bottom: vh - rect.top + MENU_GAP };
44
- }
45
- const top = Math.min(rect.bottom + MENU_GAP, Math.max(MENU_EDGE, vh - MENU_EDGE - menuH));
46
- return { ...inline, top };
47
- }
48
- /*
49
- * Shared behaviour for a dropdown portaled to document.body: fixed position
50
- * computed from the trigger's rect (so tile overflow cannot clip it),
51
- * re-anchored on window resize/scroll while open, Escape to close with focus
52
- * returned to the trigger, and portal-aware outside-pointer-down dismissal
53
- * (menuRef points at the portaled menu, so clicks inside it stay "inside").
54
- * One menu at a time falls out of the dismissal: opening another menu's
55
- * trigger is an outside press for this one.
56
- */
57
- function usePortalMenu({ maxWidth, maxHeight, align }) {
58
- const [open, setOpen] = useState(false);
59
- const [position, setPosition] = useState(null);
60
- const menuRef = useRef(null);
61
- const triggerRef = useRef(null);
62
- useEffect(() => {
63
- if (!open) {
64
- setPosition(null);
65
- return;
66
- }
67
- const update = () => {
68
- const trigger = triggerRef.current;
69
- if (trigger) {
70
- setPosition(computeMenuPosition(trigger, { maxWidth, maxHeight, align }));
71
- }
72
- };
73
- update();
74
- window.addEventListener("resize", update);
75
- window.addEventListener("scroll", update, true);
76
- return () => {
77
- window.removeEventListener("resize", update);
78
- window.removeEventListener("scroll", update, true);
79
- };
80
- }, [open, maxWidth, maxHeight, align]);
81
- useEffect(() => {
82
- if (!open)
83
- return;
84
- const onKeyDown = (e) => {
85
- if (e.key === "Escape") {
86
- e.stopPropagation();
87
- setOpen(false);
88
- triggerRef.current?.focus();
89
- }
90
- };
91
- const onPointerDown = (e) => {
92
- if (!menuRef.current?.contains(e.target) &&
93
- !triggerRef.current?.contains(e.target)) {
94
- setOpen(false);
95
- }
96
- };
97
- document.addEventListener("keydown", onKeyDown);
98
- document.addEventListener("pointerdown", onPointerDown);
99
- return () => {
100
- document.removeEventListener("keydown", onKeyDown);
101
- document.removeEventListener("pointerdown", onPointerDown);
102
- };
103
- }, [open]);
104
- const toggle = useCallback(() => setOpen((v) => !v), []);
105
- /** Close after an item pick, returning focus to the trigger. */
106
- const close = useCallback(() => {
107
- setOpen(false);
108
- triggerRef.current?.focus();
109
- }, []);
110
- return { open, toggle, close, position, menuRef, triggerRef };
111
- }
112
- /** Round n to the nearest even integer, minimum 2 (H.264 chroma requirement). */
113
- function toEvenPx(n) {
114
- return Math.max(2, Math.round(n / 2) * 2);
115
- }
116
26
  /*
117
27
  * Viewer quality presets, in menu order. Scales mirror the sidecar's
118
28
  * QualityPreset mapping (fractions of the camera's operator render size);
@@ -149,9 +59,17 @@ function exitFullscreen() {
149
59
  // ---------------------------------------------------------------------------
150
60
  // Inner component (reads from context)
151
61
  // ---------------------------------------------------------------------------
152
- const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, onDisplayedCameraChange, showDebugInfo = false, showStatic, inFlight: inFlightProp, showStandbyIcon = true, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, actions, trailingActions, useStream, }, ref) {
62
+ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, cameraFilter, onDisplayedCameraChange, showDebugInfo = false, showStatic, inFlight: inFlightProp, showStandbyIcon = true, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, enableTracking = false, enableRecording = false, recordFullResolution = false, actions, trailingActions, showActions = true, useStream, }, ref) {
153
63
  const client = useKerbcastClient();
154
- const cameras = useKerbcastCameras();
64
+ // Always called (rules of hooks); `enableRecording` only gates whether the
65
+ // REC entry/badge render, not whether the store is subscribed.
66
+ const recordings = useRecordings();
67
+ // The selectable set: every live camera, optionally narrowed by the host's
68
+ // cameraFilter (e.g. a part-grid tile offering only part cams). Drives the
69
+ // picker menu, the stepper, and the auto-latch fallback so none can land on
70
+ // a filtered-out camera.
71
+ const allCameras = useKerbcastCameras();
72
+ const cameras = useMemo(() => (cameraFilter ? allCameras.filter(cameraFilter) : allCameras), [allCameras, cameraFilter]);
155
73
  const inFlightFromClient = useKerbcastInFlight();
156
74
  const inFlight = inFlightProp ?? inFlightFromClient;
157
75
  const outOfFlight = inFlight === false;
@@ -316,6 +234,13 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
316
234
  const isDestroyed = camera ? isCameraDestroyed(camera) : false;
317
235
  const showPan = camera?.supportsPan && !isDestroyed;
318
236
  const showZoom = camera?.supportsZoom && !isDestroyed;
237
+ // Auto-track state is server-authoritative: driven purely by the published
238
+ // trackMode (absent -> none), never local click. While tracking, the aim
239
+ // loop owns the gimbal + FoV, so the manual pan/zoom controls are disabled
240
+ // (they jitter against the track and are no-ops in practice).
241
+ const trackMode = camera?.trackMode ?? TrackMode.None;
242
+ const tracking = trackMode !== TrackMode.None;
243
+ const manualDisabled = tracking;
319
244
  const supportsPitch = !!camera && camera.panPitchMax - camera.panPitchMin > 0;
320
245
  // -------------------------------------------------------------------------
321
246
  // Camera selection callbacks (Next/Prev, picker)
@@ -334,7 +259,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
334
259
  onSelectCamera?.(nextId);
335
260
  }, [cameras, currentIndex, onSelectCamera]);
336
261
  // -------------------------------------------------------------------------
337
- // PanZoomController -- one per displayed camera
262
+ // PanZoomController: one per displayed camera
338
263
  // -------------------------------------------------------------------------
339
264
  const controllerRef = useRef(null);
340
265
  const [sliderFov, setSliderFov] = useState(60);
@@ -349,8 +274,8 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
349
274
  controllerRef.current?.dispose();
350
275
  controllerRef.current = ctrl;
351
276
  const unsubSlider = ctrl.onSliderFov(setSliderFov);
352
- // Seed the slider from the controller's initial (0) -- will be overwritten
353
- // immediately by syncFromState in the camera-state effect below.
277
+ // Seed the slider from the controller's initial (0). It will be
278
+ // overwritten immediately by syncFromState in the camera-state effect below.
354
279
  setSliderFov(ctrl.sliderFov);
355
280
  return () => {
356
281
  unsubSlider();
@@ -441,36 +366,12 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
441
366
  },
442
367
  }), [stepCamera, showZoom, showPan, supportsPitch]);
443
368
  // -------------------------------------------------------------------------
444
- // Render-size feedback (ResizeObserver, 500 ms debounce, 16:9 crop)
369
+ // Display-size reporting (auto-resolution). The feed self-measures its
370
+ // rendered box and reports it per-consumer; the sidecar maxes across
371
+ // consumers. Reports the real w x h (part cams are not square). Disabled by
372
+ // `renderSize="none"`. See useReportDisplaySize for debounce / bucketing.
445
373
  // -------------------------------------------------------------------------
446
- const resizeTimerRef = useRef(null);
447
- useEffect(() => {
448
- if (renderSize !== "auto" || flightId === null)
449
- return;
450
- const el = wrapRef.current;
451
- if (!el)
452
- return;
453
- const cam = client.camera(flightId);
454
- const observer = new ResizeObserver((entries) => {
455
- const entry = entries[0];
456
- if (!entry)
457
- return;
458
- const { width } = entry.contentRect;
459
- if (resizeTimerRef.current !== null)
460
- clearTimeout(resizeTimerRef.current);
461
- resizeTimerRef.current = setTimeout(() => {
462
- const w = toEvenPx(width);
463
- const h = toEvenPx((width * 9) / 16);
464
- void cam.setRenderSize(w, h);
465
- }, 500);
466
- });
467
- observer.observe(el);
468
- return () => {
469
- observer.disconnect();
470
- if (resizeTimerRef.current !== null)
471
- clearTimeout(resizeTimerRef.current);
472
- };
473
- }, [client, flightId, renderSize]);
374
+ useReportDisplaySize(flightId, wrapRef, { enabled: renderSize === "auto" });
474
375
  // -------------------------------------------------------------------------
475
376
  // UI state
476
377
  // -------------------------------------------------------------------------
@@ -512,7 +413,81 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
512
413
  void client.camera(flightId).setQuality(preset);
513
414
  closeQualityMenu();
514
415
  }, [client, flightId, closeQualityMenu]);
515
- const topOverlay = (_jsxs(TopOverlay, { children: [_jsx(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) }) }), cameraMenu.open &&
416
+ // -------------------------------------------------------------------------
417
+ // Auto-track control (opt-in, pan+zoom cameras only). A crosshair button +
418
+ // menu with the two modes. Sends the intent; the highlight follows the
419
+ // server-published trackMode (above), so two browsers agree.
420
+ // -------------------------------------------------------------------------
421
+ const trackingAvailable = enableTracking &&
422
+ flightId !== null &&
423
+ camera !== null &&
424
+ camera.supportsPan === true &&
425
+ camera.supportsZoom === true &&
426
+ !isDestroyed;
427
+ const trackingMenuId = useId();
428
+ const trackingMenu = usePortalMenu({
429
+ maxWidth: QUALITY_MENU_MAX_WIDTH,
430
+ maxHeight: QUALITY_MENU_MAX_HEIGHT,
431
+ align: "end",
432
+ });
433
+ const closeTrackingMenu = trackingMenu.close;
434
+ const selectTrack = useCallback((mode) => {
435
+ if (flightId === null)
436
+ return;
437
+ // Tri-state: clicking the already-active mode hands aiming back (none).
438
+ // Decision reads the server-confirmed trackMode, not a local toggle.
439
+ const next = trackMode === mode ? TrackMode.None : mode;
440
+ void client.setTrackTarget(flightId, next);
441
+ closeTrackingMenu();
442
+ }, [client, flightId, trackMode, closeTrackingMenu]);
443
+ // -------------------------------------------------------------------------
444
+ // REC control (opt-in). A stateful toggle that starts/stops a client-side
445
+ // recording of this feed via the recordings store; state is read from the
446
+ // store (isRecording), never held locally, so it stays correct if the
447
+ // recording was started elsewhere (e.g. a REC+ grouped recording).
448
+ // -------------------------------------------------------------------------
449
+ const recordingActive = enableRecording && flightId !== null && recordings.isRecording(flightId);
450
+ const activeRecording = recordingActive
451
+ ? recordings.active.find((a) => a.flightId === flightId)
452
+ : undefined;
453
+ /* True while a single forced recording is armed (force sent, waiting for
454
+ the feed to reach full resolution) but the recorder has not started
455
+ yet (see ActiveRecordingInfo.arming). Drives the ARMING pill below in
456
+ place of the REC badge. */
457
+ const armingActive = activeRecording?.arming === true;
458
+ // Ticks once a second while recording so the elapsed timer stays live;
459
+ // the timer itself is derived from activeRecording.startedAt each render.
460
+ const [, tickRecordingTimer] = useReducer((c) => c + 1, 0);
461
+ useEffect(() => {
462
+ if (!recordingActive)
463
+ return;
464
+ const interval = setInterval(tickRecordingTimer, 1000);
465
+ return () => clearInterval(interval);
466
+ }, [recordingActive]);
467
+ const recordingElapsedMs = activeRecording ? nowMs() - activeRecording.startedAt : 0;
468
+ const toggleRecording = useCallback(() => {
469
+ if (flightId === null)
470
+ return;
471
+ if (recordingActive) {
472
+ const active = recordings.active.find((a) => a.flightId === flightId);
473
+ if (active) {
474
+ void recordings.stop(active.recordingId).catch(() => {
475
+ /* A rejected stop shouldn't leave the REC toggle wedged; the
476
+ store's own state (active/recordings) is the source of truth
477
+ for whether it's still "recording", not this call's outcome. */
478
+ });
479
+ }
480
+ return;
481
+ }
482
+ try {
483
+ recordings.start(flightId, { forceFullResolution: recordFullResolution });
484
+ }
485
+ catch {
486
+ /* no live track yet, or a race with a recording already in progress
487
+ elsewhere on this feed; the operator can retry once live. */
488
+ }
489
+ }, [flightId, recordingActive, recordings, recordFullResolution]);
490
+ const topOverlay = (_jsxs(TopOverlay, { children: [_jsx(TitleRow, { children: _jsx(TopTitle, { children: hasCameras ? (_jsxs(TitleButton, { ref: cameraMenu.triggerRef, type: "button", "aria-haspopup": "menu", "aria-expanded": cameraMenu.open, "aria-controls": menuId, disabled: recordingActive, title: recordingActive ? "Stop recording to change camera" : undefined, onClick: recordingActive ? undefined : cameraMenu.toggle, children: [_jsx(TitleButton__Text, { children: title }), _jsx(ChevronDownIcon, { "aria-hidden": "true" })] })) : (title) }) }), cameraMenu.open &&
516
491
  hasCameras &&
517
492
  cameraMenu.position &&
518
493
  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: () => {
@@ -520,23 +495,117 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
520
495
  cameraMenu.close();
521
496
  }, children: [cameraLabel(c), " (", c.vesselName, ")", isCameraDestroyed(c) ? " - signal lost" : ""] }, c.flightId))) }), document.body), showDebugInfo &&
522
497
  (camera ? (_jsxs(TopMeta, { children: [camera.vesselName, " \u00B7 ", camera.renderWidth, "\u00D7", camera.renderHeight, bitrateLabel, adaptiveLabel] })) : (_jsx(TopMeta, { children: "no cameras on this vessel" })))] }));
523
- 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));
524
- const builtInActions = flightId !== null && (pipAvailable || fullscreenAvailable || qualityAvailable);
525
- const hasActionBar = hasCameras ||
526
- (actions && actions.length > 0) ||
527
- (trailingActions && trailingActions.length > 0) ||
528
- builtInActions;
529
- const actionBar = hasActionBar ? (_jsxs(ActionBar, { children: [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" })] })), 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
530
- ? "Quality (throttled by adaptive performance)"
531
- : "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;
498
+ /*
499
+ * Built-in action-bar entries, in the bar's natural (fallback) order:
500
+ * consumer actions, quality, tracking, PiP, fullscreen, trailing actions.
501
+ * FeedActionBar partitions these into primary/overflow (see its own
502
+ * doc-comment); tracking is the only built-in marked `stateful` today,
503
+ * per #6's "stateful toggles stay primary" rule (REC joins it the same
504
+ * way).
505
+ */
506
+ const trackingLabel = tracking
507
+ ? trackMode === TrackMode.Target
508
+ ? "Auto-tracking target"
509
+ : "Auto-tracking active vessel"
510
+ : "Auto-track a vessel";
511
+ const pipLabel = isPip ? "Exit picture in picture" : "Picture in picture";
512
+ const fullscreenLabel = isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
513
+ const recordingLabel = recordingActive ? "Stop recording" : "Start recording";
514
+ const entries = [
515
+ ...(actions?.map(feedActionToEntry) ?? []),
516
+ ...(qualityAvailable
517
+ ? [
518
+ {
519
+ id: "quality",
520
+ label: "Quality",
521
+ stateful: false,
522
+ render: () => (_jsxs(OverlayIconButton, { ref: qualityMenu.triggerRef, type: "button", "aria-label": "Quality", "aria-haspopup": "menu", "aria-expanded": qualityMenu.open, "aria-controls": qualityMenuId, title: qualityThrottled
523
+ ? "Quality (throttled by adaptive performance)"
524
+ : "Quality", "$active": qualityMenu.open, onClick: qualityMenu.toggle, children: [_jsx(QualityIcon, {}), qualityThrottled && _jsx(ThrottledDot, { "aria-hidden": "true" })] })),
525
+ },
526
+ ]
527
+ : []),
528
+ ...(enableRecording && flightId !== null
529
+ ? [
530
+ {
531
+ id: "record",
532
+ label: recordingLabel,
533
+ stateful: true,
534
+ render: () => (_jsx(RecordActionButton, { type: "button", "aria-label": recordingLabel, "aria-pressed": recordingActive, title: recordingLabel, "$active": recordingActive, onClick: toggleRecording, children: _jsx(RecordIcon, {}) })),
535
+ },
536
+ ]
537
+ : []),
538
+ ...(trackingAvailable
539
+ ? [
540
+ {
541
+ id: "tracking",
542
+ label: trackingLabel,
543
+ stateful: true,
544
+ render: () => (_jsx(OverlayIconButton, { ref: trackingMenu.triggerRef, type: "button", "aria-label": "Auto-track", "aria-haspopup": "menu", "aria-expanded": trackingMenu.open, "aria-controls": trackingMenuId, "aria-pressed": tracking, title: trackingLabel, "$active": tracking || trackingMenu.open, onClick: trackingMenu.toggle, children: _jsx(CrosshairIcon, {}) })),
545
+ },
546
+ ]
547
+ : []),
548
+ ...(flightId !== null && pipAvailable
549
+ ? [
550
+ {
551
+ id: "pip",
552
+ label: pipLabel,
553
+ stateful: false,
554
+ render: () => (_jsx(OverlayIconButton, { type: "button", "aria-label": pipLabel, "aria-pressed": isPip, title: pipLabel, "$active": isPip, onClick: togglePip, children: _jsx(PictureInPictureIcon, {}) })),
555
+ },
556
+ ]
557
+ : []),
558
+ ...(flightId !== null && fullscreenAvailable
559
+ ? [
560
+ {
561
+ id: "fullscreen",
562
+ label: fullscreenLabel,
563
+ stateful: false,
564
+ render: () => (_jsx(OverlayIconButton, { type: "button", "aria-label": fullscreenLabel, "aria-pressed": isFullscreen, title: fullscreenLabel, "$active": isFullscreen, onClick: toggleFullscreen, children: isFullscreen ? _jsx(FullscreenExitIcon, {}) : _jsx(FullscreenEnterIcon, {}) })),
565
+ },
566
+ ]
567
+ : []),
568
+ /* trailingActions is always the pinned-trailing (close/remove) slot,
569
+ regardless of what the consumer's FeedAction sets. See its own
570
+ doc-comment on CameraFeedProps. While a recording is active, closing
571
+ the feed would lose it, so the control stays visible but disabled
572
+ and inert rather than disappearing. */
573
+ ...(trailingActions?.map((a) => {
574
+ const entry = feedActionToEntry(a);
575
+ if (!recordingActive)
576
+ return { ...entry, pinnedTrailing: true };
577
+ return {
578
+ ...entry,
579
+ pinnedTrailing: true,
580
+ render: () => (_jsx(OverlayIconButton, { type: "button", "aria-label": a.label, title: "Stop recording to close this feed", disabled: true, "$active": a.active ?? false, children: a.icon }, a.id)),
581
+ };
582
+ }) ?? []),
583
+ ];
584
+ const builtInActions = flightId !== null &&
585
+ (pipAvailable ||
586
+ fullscreenAvailable ||
587
+ qualityAvailable ||
588
+ trackingAvailable ||
589
+ enableRecording);
590
+ const hasActionBar = showActions &&
591
+ (hasCameras ||
592
+ (actions && actions.length > 0) ||
593
+ (trailingActions && trailingActions.length > 0) ||
594
+ builtInActions);
595
+ const stepButtons = 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" })] })) : null;
596
+ const actionBar = hasActionBar ? (_jsx(FeedActionBar, { leading: stepButtons, entries: entries })) : null;
532
597
  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 &&
533
598
  qualityMenu.open &&
534
599
  qualityMenu.position &&
535
600
  camera &&
536
- 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), outOfFlight ? (showStandbyIcon && (_jsx(StandbyOverlay, { role: "status", "aria-label": "Standby, no active flight", children: _jsx(StandbyIconWrap, { children: _jsx(StandbyIcon, { size: 40 }) }) }))) : (isDestroyed && (_jsx(SignalLostOverlay, { role: "status", "aria-label": "Signal lost", children: _jsx(SignalLostText, { "$animated": effectiveShowStatic, children: "SIGNAL LOST" }) }))), !outOfFlight && !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) => {
601
+ 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), trackingAvailable &&
602
+ trackingMenu.open &&
603
+ trackingMenu.position &&
604
+ camera &&
605
+ createPortal(_jsxs(QualityMenu, { ref: trackingMenu.menuRef, id: trackingMenuId, role: "menu", "aria-label": "Auto-track", style: trackingMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.ActiveVessel, "$selected": trackMode === TrackMode.ActiveVessel, onClick: () => selectTrack(TrackMode.ActiveVessel), children: "Track active vessel" }), _jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.Target, "$selected": trackMode === TrackMode.Target, onClick: () => selectTrack(TrackMode.Target), children: "Track target" })] }), document.body), outOfFlight ? (showStandbyIcon && (_jsx(StandbyOverlay, { role: "status", "aria-label": "Standby, no active flight", children: _jsx(StandbyIconWrap, { children: _jsx(StandbyIcon, { size: 40 }) }) }))) : (isDestroyed && (_jsx(SignalLostOverlay, { role: "status", "aria-label": "Signal lost", children: _jsx(SignalLostText, { "$animated": effectiveShowStatic, children: "SIGNAL LOST" }) }))), !outOfFlight && !effectiveShowStatic && isStale && !isDestroyed && (_jsxs(_Fragment, { children: [_jsx(StaleScrim, { "aria-hidden": "true" }), _jsxs(StaleBadge, { role: "status", "aria-label": "Feed stale", children: [_jsx(StaleIcon, {}), "Stale"] })] })), recordingActive && armingActive && (_jsxs(ArmingBadge, { role: "status", "aria-label": "Arming", children: [_jsx(ArmingDot, { "aria-hidden": "true" }), "ARMING"] })), recordingActive && !armingActive && (_jsxs(RecBadge, { role: "status", "aria-label": "Recording", children: [_jsx(RecDot, { "aria-hidden": "true" }), "REC ", formatElapsed(recordingElapsedMs)] })), showZoom && (_jsxs(ZoomControlsWrap, { "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(ZoomButton, { type: "button", "aria-label": "Zoom in", "$pos": "top", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
537
606
  if (e.detail === 0)
538
607
  controllerRef.current?.nudgeZoom(-1);
539
- }, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, onChange: (e) => {
608
+ }, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
540
609
  const v = Number(e.target.value);
541
610
  controllerRef.current?.fovSliderInput(v);
542
611
  }, onPointerDown: () => {
@@ -545,10 +614,10 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
545
614
  controllerRef.current?.setFovSliderDragging(false);
546
615
  }, onPointerCancel: () => {
547
616
  controllerRef.current?.setFovSliderDragging(false);
548
- } }), _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) => {
617
+ } }), _jsx(ZoomButton, { type: "button", "aria-label": "Zoom out", "$pos": "bottom", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(-1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
549
618
  if (e.detail === 0)
550
619
  controllerRef.current?.nudgeZoom(1);
551
- }, 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: {
620
+ }, children: "\u2212" })] })), showPan && (_jsxs(PanControl, { role: "group", "aria-label": "Pan camera", "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(PanArrow, { type: "button", "$dir": "up", "aria-label": "Pan up", disabled: manualDisabled || !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, 1), children: "\u25B2" }), _jsx(PanArrow, { type: "button", "$dir": "down", "aria-label": "Pan down", disabled: manualDisabled || !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, -1), children: "\u25BC" }), _jsx(PanArrow, { type: "button", "$dir": "left", "aria-label": "Pan left", disabled: manualDisabled, onClick: () => controllerRef.current?.nudgePan(-1, 0), children: "\u25C0" }), _jsx(PanArrow, { type: "button", "$dir": "right", "aria-label": "Pan right", disabled: manualDisabled, onClick: () => controllerRef.current?.nudgePan(1, 0), children: "\u25B6" }), _jsx(PanBall, { "aria-hidden": "true", title: "Drag to pan", onPointerDown: manualDisabled ? undefined : handleBallDown, onPointerMove: manualDisabled ? undefined : handleBallMove, onPointerUp: manualDisabled ? undefined : handleBallUp, onPointerCancel: manualDisabled ? undefined : handleBallUp, style: {
552
621
  transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
553
622
  } })] }))] })) }));
554
623
  });
@@ -593,6 +662,10 @@ function FullscreenExitIcon() {
593
662
  function QualityIcon() {
594
663
  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" })] }));
595
664
  }
665
+ /* Auto-track: a crosshair / reticle. */
666
+ function CrosshairIcon() {
667
+ return (_jsxs("svg", { ...iconProps, children: [_jsx("circle", { cx: "8", cy: "8", r: "4.5" }), _jsx("path", { d: "M8 1v2.5M8 12.5V15M1 8h2.5M12.5 8H15" })] }));
668
+ }
596
669
  /* Stale badge: fading signal bars (tallest dimmed). */
597
670
  function StaleIcon() {
598
671
  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" })] }));
@@ -600,6 +673,10 @@ function StaleIcon() {
600
673
  function PictureInPictureIcon() {
601
674
  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" })] }));
602
675
  }
676
+ /* REC: a plain filled dot, coloured by RecordActionButton's own $active state. */
677
+ function RecordIcon() {
678
+ return (_jsx("svg", { viewBox: "0 0 16 16", width: 14, height: 14, fill: "currentColor", stroke: "none", "aria-hidden": "true", children: _jsx("circle", { cx: "8", cy: "8", r: "5" }) }));
679
+ }
603
680
  // ---------------------------------------------------------------------------
604
681
  // Styled components
605
682
  // ---------------------------------------------------------------------------
@@ -613,6 +690,9 @@ const PanControl = styled.div `
613
690
  transition: opacity 0.15s;
614
691
  touch-action: none;
615
692
 
693
+ /* Greyed + inert while auto-tracking owns the gimbal. */
694
+ ${(p) => p.$disabled && "pointer-events: none; filter: grayscale(1) opacity(0.4);"}
695
+
616
696
  @media (prefers-reduced-motion: reduce) {
617
697
  transition: none;
618
698
  }
@@ -692,6 +772,9 @@ const ZoomControlsWrap = styled.div `
692
772
  opacity: 0;
693
773
  transition: opacity 0.15s;
694
774
 
775
+ /* Greyed + inert while auto-tracking owns the FoV. */
776
+ ${(p) => p.$disabled && "pointer-events: none; filter: grayscale(1) opacity(0.4);"}
777
+
695
778
  @media (prefers-reduced-motion: reduce) {
696
779
  transition: none;
697
780
  }
@@ -901,21 +984,107 @@ const ThrottledDot = styled.span `
901
984
  background: #ffb347;
902
985
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6);
903
986
  `;
904
- /* Top-right cluster of overlay controls (custom actions + fullscreen/PiP). */
905
- const ActionBar = styled.div `
987
+ /* REC action-bar button: same shape as OverlayIconButton, its own red
988
+ $active colour (a coming-recording toggle should never read as the
989
+ green "on" of tracking/quality) via its own token so a consumer can
990
+ recolour it without touching --kerbcast-action-active. */
991
+ const RecordActionButton = styled(OverlayIconButton) `
992
+ background: ${(p) => p.$active ? "var(--kerbcast-rec-active, #ff3b30)" : "rgba(0, 0, 0, 0.5)"};
993
+ border-color: ${(p) => p.$active ? "var(--kerbcast-rec-active, #ff3b30)" : "rgba(255, 255, 255, 0.3)"};
994
+ color: #fff;
995
+
996
+ @media (hover: hover) {
997
+ &:hover {
998
+ background: ${(p) => p.$active ? "var(--kerbcast-rec-active, #ff3b30)" : "rgba(0, 0, 0, 0.7)"};
999
+ }
1000
+ }
1001
+ `;
1002
+ /* Persistent recording badge: red dot + elapsed timer, always visible while
1003
+ recording (not hover chrome), same convention as StaleBadge. */
1004
+ const RecBadge = styled.div `
906
1005
  position: absolute;
907
- top: 6px;
908
- right: 8px;
909
- z-index: 3;
1006
+ /* Sits just under the hover-gated title/source dropdown (both are top-left)
1007
+ so an active recording stays visible without displacing that control. */
1008
+ top: 28px;
1009
+ left: 8px;
1010
+ z-index: 2;
910
1011
  display: flex;
911
1012
  align-items: center;
912
- gap: 4px;
913
- opacity: 0;
1013
+ gap: 5px;
1014
+ padding: 2px 6px;
1015
+ background: rgba(0, 0, 0, 0.6);
1016
+ border: 1px solid var(--kerbcast-rec-active, #ff3b30);
1017
+ border-radius: 3px;
1018
+ color: #fff;
1019
+ font-size: 10px;
1020
+ font-weight: 600;
1021
+ letter-spacing: 0.12em;
1022
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
914
1023
  pointer-events: none;
915
- transition: opacity 0.15s;
1024
+ `;
1025
+ const RecDot = styled.span `
1026
+ width: 7px;
1027
+ height: 7px;
1028
+ border-radius: 50%;
1029
+ background: var(--kerbcast-rec-active, #ff3b30);
1030
+ flex-shrink: 0;
916
1031
 
917
- @media (prefers-reduced-motion: reduce) {
918
- transition: none;
1032
+ @media (prefers-reduced-motion: no-preference) {
1033
+ animation: rec-dot-pulse 1.6s ease-in-out infinite;
1034
+ }
1035
+
1036
+ @keyframes rec-dot-pulse {
1037
+ 0%,
1038
+ 100% {
1039
+ opacity: 1;
1040
+ }
1041
+ 50% {
1042
+ opacity: 0.4;
1043
+ }
1044
+ }
1045
+ `;
1046
+ /* Arming pill: same slot as RecBadge (a single forced recording shows one or
1047
+ the other, never both), a muted amber so it never reads as the red
1048
+ "recording is live" state: the feed hasn't actually started capturing
1049
+ yet, it's waiting for the resolution bump. */
1050
+ const ArmingBadge = styled.div `
1051
+ position: absolute;
1052
+ top: 28px;
1053
+ left: 8px;
1054
+ z-index: 2;
1055
+ display: flex;
1056
+ align-items: center;
1057
+ gap: 5px;
1058
+ padding: 2px 6px;
1059
+ background: rgba(0, 0, 0, 0.6);
1060
+ border: 1px solid var(--kerbcast-arming-active, #d9a441);
1061
+ border-radius: 3px;
1062
+ color: #fff;
1063
+ font-size: 10px;
1064
+ font-weight: 600;
1065
+ letter-spacing: 0.12em;
1066
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
1067
+ pointer-events: none;
1068
+ `;
1069
+ const ArmingDot = styled.span `
1070
+ width: 7px;
1071
+ height: 7px;
1072
+ border-radius: 50%;
1073
+ background: var(--kerbcast-arming-active, #d9a441);
1074
+ flex-shrink: 0;
1075
+
1076
+ @media (prefers-reduced-motion: no-preference) {
1077
+ animation: arming-dot-pulse 0.9s ease-in-out infinite;
1078
+ }
1079
+
1080
+ @keyframes arming-dot-pulse {
1081
+ 0%,
1082
+ 100% {
1083
+ opacity: 1;
1084
+ }
1085
+ 50% {
1086
+ opacity: 0.3;
1087
+ }
919
1088
  }
920
1089
  `;
921
1090
  /*
@@ -948,14 +1117,14 @@ const Stage = styled(Panel) `
948
1117
  &:focus-within ${ZoomControlsWrap},
949
1118
  &:hover ${PanControl},
950
1119
  &:focus-within ${PanControl},
951
- &:hover ${ActionBar},
952
- &:focus-within ${ActionBar} {
1120
+ &:hover ${ActionBarRow},
1121
+ &:focus-within ${ActionBarRow} {
953
1122
  opacity: 1;
954
1123
  }
955
1124
  &:hover ${TopOverlay},
956
1125
  &:focus-within ${TopOverlay},
957
- &:hover ${ActionBar},
958
- &:focus-within ${ActionBar} {
1126
+ &:hover ${ActionBarRow},
1127
+ &:focus-within ${ActionBarRow} {
959
1128
  pointer-events: auto;
960
1129
  }
961
1130
 
@@ -965,7 +1134,7 @@ const Stage = styled(Panel) `
965
1134
  opacity: 1;
966
1135
  pointer-events: auto;
967
1136
  }
968
- ${ActionBar} {
1137
+ ${ActionBarRow} {
969
1138
  opacity: 1;
970
1139
  pointer-events: auto;
971
1140
  }
@@ -982,48 +1151,6 @@ const Empty = styled.div `
982
1151
  padding: 1rem;
983
1152
  text-align: center;
984
1153
  `;
985
- const OverlayIconButton = styled.button `
986
- position: relative;
987
- width: 24px;
988
- height: 24px;
989
- display: flex;
990
- align-items: center;
991
- justify-content: center;
992
- padding: 0;
993
- background: ${(p) => p.$active
994
- ? "var(--kerbcast-accent, #00ff88)"
995
- : "rgba(0, 0, 0, 0.5)"};
996
- border: 1px solid
997
- ${(p) => p.$active
998
- ? "var(--kerbcast-accent, #00ff88)"
999
- : "rgba(255, 255, 255, 0.3)"};
1000
- border-radius: 3px;
1001
- color: ${(p) => (p.$active ? "#000" : "#fff")};
1002
- cursor: pointer;
1003
- transition: background 0.12s, border-color 0.12s, color 0.12s;
1004
-
1005
- @media (hover: hover) {
1006
- &:hover {
1007
- background: ${(p) => p.$active
1008
- ? "var(--kerbcast-accent, #00ff88)"
1009
- : "rgba(0, 0, 0, 0.7)"};
1010
- border-color: rgba(255, 255, 255, 0.6);
1011
- }
1012
- }
1013
-
1014
- &:focus-visible {
1015
- outline: 2px solid var(--kerbcast-accent, #00ff88);
1016
- outline-offset: 2px;
1017
- }
1018
-
1019
- &:disabled {
1020
- opacity: 0.4;
1021
- }
1022
-
1023
- @media (prefers-reduced-motion: reduce) {
1024
- transition: none;
1025
- }
1026
- `;
1027
1154
  const StyledVideo = styled.video `
1028
1155
  position: absolute;
1029
1156
  inset: 0;