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