@ksp-gonogo/kerbcast-react 1.7.0 → 1.8.1

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.
@@ -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,12 +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";
20
30
  import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
21
31
  import { StandbyIcon } from "./StandbyIcon";
22
32
  import { isCameraDestroyed } from "./lifecycle";
33
+ import { usePortalMenu } from "./menuPositioning";
34
+ import { formatElapsed, nowMs } from "./timing";
23
35
 
24
36
  // ---------------------------------------------------------------------------
25
37
  // Tuning constants
@@ -29,124 +41,6 @@ const MENU_MAX_WIDTH = 260; // matches CameraMenu's CSS cap
29
41
  const MENU_MAX_HEIGHT = 300; // matches CameraMenu's min(40vh, 300px) cap
30
42
  const QUALITY_MENU_MAX_WIDTH = 220; // matches QualityMenu's CSS cap
31
43
  const QUALITY_MENU_MAX_HEIGHT = 220; // matches QualityMenu's min(40vh, 220px) cap
32
- const MENU_GAP = 4; // trigger-to-menu spacing
33
- const MENU_EDGE = 8; // minimum inset from the viewport edge
34
-
35
- /*
36
- * Geometry of a portaled dropdown: the CSS size caps of the menu (the
37
- * helper clamps as if the menu fills them) and which edge of the trigger
38
- * the menu hangs from: "start" lines the menu's left edge up with the
39
- * trigger's (camera picker), "end" lines the right edges up (quality
40
- * button at the action bar's corner).
41
- */
42
- interface MenuAnchor {
43
- maxWidth: number;
44
- maxHeight: number;
45
- align: "start" | "end";
46
- }
47
-
48
- /*
49
- * Fixed-position style for a portaled menu, anchored to its trigger button.
50
- * Opens downward by default; flips above the trigger when there is not
51
- * enough room below but there is above, otherwise clamps to the viewport.
52
- */
53
- function computeMenuPosition(
54
- trigger: HTMLElement,
55
- anchor: MenuAnchor,
56
- ): React.CSSProperties {
57
- const rect = trigger.getBoundingClientRect();
58
- const vw = window.innerWidth;
59
- const vh = window.innerHeight;
60
- const width = Math.min(anchor.maxWidth, vw - 2 * MENU_EDGE);
61
- /* Inline offset from the aligned viewport edge, clamped so a full-width
62
- menu still fits inside the opposite edge. */
63
- const inset = Math.max(MENU_EDGE, vw - width - MENU_EDGE);
64
- const inline: React.CSSProperties =
65
- anchor.align === "start"
66
- ? { left: Math.min(Math.max(rect.left, MENU_EDGE), inset) }
67
- : { right: Math.min(Math.max(vw - rect.right, MENU_EDGE), inset) };
68
- const menuH = Math.min(0.4 * vh, anchor.maxHeight);
69
- const fitsBelow = rect.bottom + MENU_GAP + menuH <= vh - MENU_EDGE;
70
- const fitsAbove = rect.top - MENU_GAP - menuH >= MENU_EDGE;
71
- if (!fitsBelow && fitsAbove) {
72
- return { ...inline, bottom: vh - rect.top + MENU_GAP };
73
- }
74
- const top = Math.min(
75
- rect.bottom + MENU_GAP,
76
- Math.max(MENU_EDGE, vh - MENU_EDGE - menuH),
77
- );
78
- return { ...inline, top };
79
- }
80
-
81
- /*
82
- * Shared behaviour for a dropdown portaled to document.body: fixed position
83
- * computed from the trigger's rect (so tile overflow cannot clip it),
84
- * re-anchored on window resize/scroll while open, Escape to close with focus
85
- * returned to the trigger, and portal-aware outside-pointer-down dismissal
86
- * (menuRef points at the portaled menu, so clicks inside it stay "inside").
87
- * One menu at a time falls out of the dismissal: opening another menu's
88
- * trigger is an outside press for this one.
89
- */
90
- function usePortalMenu({ maxWidth, maxHeight, align }: MenuAnchor) {
91
- const [open, setOpen] = useState(false);
92
- const [position, setPosition] = useState<React.CSSProperties | null>(null);
93
- const menuRef = useRef<HTMLDivElement>(null);
94
- const triggerRef = useRef<HTMLButtonElement>(null);
95
-
96
- useEffect(() => {
97
- if (!open) {
98
- setPosition(null);
99
- return;
100
- }
101
- const update = () => {
102
- const trigger = triggerRef.current;
103
- if (trigger) {
104
- setPosition(computeMenuPosition(trigger, { maxWidth, maxHeight, align }));
105
- }
106
- };
107
- update();
108
- window.addEventListener("resize", update);
109
- window.addEventListener("scroll", update, true);
110
- return () => {
111
- window.removeEventListener("resize", update);
112
- window.removeEventListener("scroll", update, true);
113
- };
114
- }, [open, maxWidth, maxHeight, align]);
115
-
116
- useEffect(() => {
117
- if (!open) return;
118
- const onKeyDown = (e: KeyboardEvent) => {
119
- if (e.key === "Escape") {
120
- e.stopPropagation();
121
- setOpen(false);
122
- triggerRef.current?.focus();
123
- }
124
- };
125
- const onPointerDown = (e: PointerEvent) => {
126
- if (
127
- !menuRef.current?.contains(e.target as Node) &&
128
- !triggerRef.current?.contains(e.target as Node)
129
- ) {
130
- setOpen(false);
131
- }
132
- };
133
- document.addEventListener("keydown", onKeyDown);
134
- document.addEventListener("pointerdown", onPointerDown);
135
- return () => {
136
- document.removeEventListener("keydown", onKeyDown);
137
- document.removeEventListener("pointerdown", onPointerDown);
138
- };
139
- }, [open]);
140
-
141
- const toggle = useCallback(() => setOpen((v) => !v), []);
142
- /** Close after an item pick, returning focus to the trigger. */
143
- const close = useCallback(() => {
144
- setOpen(false);
145
- triggerRef.current?.focus();
146
- }, []);
147
-
148
- return { open, toggle, close, position, menuRef, triggerRef };
149
- }
150
44
 
151
45
  /*
152
46
  * Viewer quality presets, in menu order. Scales mirror the sidecar's
@@ -209,21 +103,11 @@ function exitFullscreen(): void {
209
103
  // ---------------------------------------------------------------------------
210
104
 
211
105
  /**
212
- * A consumer-supplied action rendered into the feed's top-right action bar.
213
- * Lets a host page (e.g. the sidecar's spotlight toggle) inject controls
214
- * 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`.
215
109
  */
216
- export interface FeedAction {
217
- /** Stable identity for the React key. */
218
- id: string;
219
- /** Accessible label; used for aria-label and the native tooltip. */
220
- label: string;
221
- /** Icon node, sized by the action bar (~14px). */
222
- icon: React.ReactNode;
223
- /** Toggle state: renders the button highlighted and sets aria-pressed. */
224
- active?: boolean;
225
- onClick: () => void;
226
- }
110
+ export type { FeedAction } from "./FeedActionBar";
227
111
 
228
112
  /**
229
113
  * A hook that yields the live `MediaStream` for a resolved flightId. Must be
@@ -336,6 +220,29 @@ export interface CameraFeedProps {
336
220
  * the gimbal + FoV). Default false.
337
221
  */
338
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;
339
246
  /**
340
247
  * Consumer-injected action buttons, rendered left of the built-in
341
248
  * fullscreen/PiP controls in the top-right action bar.
@@ -343,8 +250,10 @@ export interface CameraFeedProps {
343
250
  actions?: FeedAction[];
344
251
  /**
345
252
  * Consumer-injected action buttons rendered at the far end of the action
346
- * bar, right of the built-in controls — the natural home for a close/remove
347
- * 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.
348
257
  */
349
258
  trailingActions?: FeedAction[];
350
259
  /**
@@ -393,6 +302,8 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
393
302
  enablePictureInPicture = false,
394
303
  enableQualityControl = false,
395
304
  enableTracking = false,
305
+ enableRecording = false,
306
+ recordFullResolution = false,
396
307
  actions,
397
308
  trailingActions,
398
309
  showActions = true,
@@ -401,6 +312,9 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
401
312
  ref,
402
313
  ) {
403
314
  const client = useKerbcastClient();
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();
404
318
  // The selectable set: every live camera, optionally narrowed by the host's
405
319
  // cameraFilter (e.g. a part-grid tile offering only part cams). Drives the
406
320
  // picker menu, the stepper, and the auto-latch fallback so none can land on
@@ -619,7 +533,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
619
533
  );
620
534
 
621
535
  // -------------------------------------------------------------------------
622
- // PanZoomController -- one per displayed camera
536
+ // PanZoomController: one per displayed camera
623
537
  // -------------------------------------------------------------------------
624
538
  const controllerRef = useRef<PanZoomController | null>(null);
625
539
  const [sliderFov, setSliderFov] = useState(60);
@@ -636,8 +550,8 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
636
550
  controllerRef.current = ctrl;
637
551
 
638
552
  const unsubSlider = ctrl.onSliderFov(setSliderFov);
639
- // Seed the slider from the controller's initial (0) -- will be overwritten
640
- // 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.
641
555
  setSliderFov(ctrl.sliderFov);
642
556
 
643
557
  return () => {
@@ -828,6 +742,55 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
828
742
  [client, flightId, trackMode, closeTrackingMenu],
829
743
  );
830
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
+
831
794
  const topOverlay = (
832
795
  <TopOverlay>
833
796
  <TitleRow>
@@ -839,7 +802,9 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
839
802
  aria-haspopup="menu"
840
803
  aria-expanded={cameraMenu.open}
841
804
  aria-controls={menuId}
842
- onClick={cameraMenu.toggle}
805
+ disabled={recordingActive}
806
+ title={recordingActive ? "Stop recording to change camera" : undefined}
807
+ onClick={recordingActive ? undefined : cameraMenu.toggle}
843
808
  >
844
809
  <TitleButton__Text>{title}</TitleButton__Text>
845
810
  <ChevronDownIcon aria-hidden="true" />
@@ -894,121 +859,205 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
894
859
  </TopOverlay>
895
860
  );
896
861
 
897
- const renderAction = (a: FeedAction) => (
898
- <OverlayIconButton
899
- key={a.id}
900
- type="button"
901
- aria-label={a.label}
902
- aria-pressed={a.active ?? undefined}
903
- title={a.label}
904
- $active={a.active ?? false}
905
- onClick={a.onClick}
906
- >
907
- {a.icon}
908
- </OverlayIconButton>
909
- );
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: () => (
1010
+ <OverlayIconButton
1011
+ key={a.id}
1012
+ type="button"
1013
+ aria-label={a.label}
1014
+ title="Stop recording to close this feed"
1015
+ disabled
1016
+ $active={a.active ?? false}
1017
+ >
1018
+ {a.icon}
1019
+ </OverlayIconButton>
1020
+ ),
1021
+ };
1022
+ }) ?? []),
1023
+ ];
910
1024
 
911
1025
  const builtInActions =
912
1026
  flightId !== null &&
913
- (pipAvailable || fullscreenAvailable || qualityAvailable || trackingAvailable);
1027
+ (pipAvailable ||
1028
+ fullscreenAvailable ||
1029
+ qualityAvailable ||
1030
+ trackingAvailable ||
1031
+ enableRecording);
914
1032
  const hasActionBar =
915
1033
  showActions &&
916
1034
  (hasCameras ||
917
1035
  (actions && actions.length > 0) ||
918
1036
  (trailingActions && trailingActions.length > 0) ||
919
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;
920
1058
  const actionBar = hasActionBar ? (
921
- <ActionBar>
922
- {hasCameras && (
923
- <StepButtons>
924
- <OverlayIconButton
925
- type="button"
926
- aria-label="Previous camera"
927
- disabled={!canStep}
928
- onClick={() => stepCamera(-1)}
929
- >
930
- &#8249;
931
- </OverlayIconButton>
932
- <OverlayIconButton
933
- type="button"
934
- aria-label="Next camera"
935
- disabled={!canStep}
936
- onClick={() => stepCamera(1)}
937
- >
938
- &#8250;
939
- </OverlayIconButton>
940
- </StepButtons>
941
- )}
942
- {actions?.map(renderAction)}
943
- {qualityAvailable && (
944
- <OverlayIconButton
945
- ref={qualityMenu.triggerRef}
946
- type="button"
947
- aria-label="Quality"
948
- aria-haspopup="menu"
949
- aria-expanded={qualityMenu.open}
950
- aria-controls={qualityMenuId}
951
- title={
952
- qualityThrottled
953
- ? "Quality (throttled by adaptive performance)"
954
- : "Quality"
955
- }
956
- $active={qualityMenu.open}
957
- onClick={qualityMenu.toggle}
958
- >
959
- <QualityIcon />
960
- {qualityThrottled && <ThrottledDot aria-hidden="true" />}
961
- </OverlayIconButton>
962
- )}
963
- {trackingAvailable && (
964
- <OverlayIconButton
965
- ref={trackingMenu.triggerRef}
966
- type="button"
967
- aria-label="Auto-track"
968
- aria-haspopup="menu"
969
- aria-expanded={trackingMenu.open}
970
- aria-controls={trackingMenuId}
971
- aria-pressed={tracking}
972
- title={
973
- tracking
974
- ? trackMode === TrackMode.Target
975
- ? "Auto-tracking target"
976
- : "Auto-tracking active vessel"
977
- : "Auto-track a vessel"
978
- }
979
- $active={tracking || trackingMenu.open}
980
- onClick={trackingMenu.toggle}
981
- >
982
- <CrosshairIcon />
983
- </OverlayIconButton>
984
- )}
985
- {flightId !== null && pipAvailable && (
986
- <OverlayIconButton
987
- type="button"
988
- aria-label={isPip ? "Exit picture in picture" : "Picture in picture"}
989
- aria-pressed={isPip}
990
- title={isPip ? "Exit picture in picture" : "Picture in picture"}
991
- $active={isPip}
992
- onClick={togglePip}
993
- >
994
- <PictureInPictureIcon />
995
- </OverlayIconButton>
996
- )}
997
- {flightId !== null && fullscreenAvailable && (
998
- <OverlayIconButton
999
- type="button"
1000
- aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
1001
- aria-pressed={isFullscreen}
1002
- title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
1003
- $active={isFullscreen}
1004
- onClick={toggleFullscreen}
1005
- >
1006
- {isFullscreen ? <FullscreenExitIcon /> : <FullscreenEnterIcon />}
1007
- </OverlayIconButton>
1008
- )}
1009
- {trailingActions?.map(renderAction)}
1010
- </ActionBar>
1011
- ) : null;
1059
+ <FeedActionBar leading={stepButtons} entries={entries} />
1060
+ ) : null;
1012
1061
 
1013
1062
  return (
1014
1063
  <Stage ref={wrapRef} $pinned={chromePinned}>
@@ -1130,6 +1179,18 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1130
1179
  </StaleBadge>
1131
1180
  </>
1132
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
+ )}
1133
1194
  {showZoom && (
1134
1195
  <ZoomControlsWrap $disabled={manualDisabled} aria-hidden={manualDisabled}>
1135
1196
  <ZoomButton
@@ -1367,6 +1428,15 @@ function PictureInPictureIcon() {
1367
1428
  );
1368
1429
  }
1369
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
+
1370
1440
  // ---------------------------------------------------------------------------
1371
1441
  // Styled components
1372
1442
  // ---------------------------------------------------------------------------
@@ -1695,21 +1765,114 @@ const ThrottledDot = styled.span`
1695
1765
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6);
1696
1766
  `;
1697
1767
 
1698
- /* Top-right cluster of overlay controls (custom actions + fullscreen/PiP). */
1699
- 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`
1700
1790
  position: absolute;
1701
- top: 6px;
1702
- right: 8px;
1703
- 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;
1704
1796
  display: flex;
1705
1797
  align-items: center;
1706
- gap: 4px;
1707
- 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);
1708
1808
  pointer-events: none;
1709
- transition: opacity 0.15s;
1809
+ `;
1710
1810
 
1711
- @media (prefers-reduced-motion: reduce) {
1712
- 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
+ }
1713
1876
  }
1714
1877
  `;
1715
1878
 
@@ -1744,14 +1907,14 @@ const Stage = styled(Panel)<{ $pinned: boolean }>`
1744
1907
  &:focus-within ${ZoomControlsWrap},
1745
1908
  &:hover ${PanControl},
1746
1909
  &:focus-within ${PanControl},
1747
- &:hover ${ActionBar},
1748
- &:focus-within ${ActionBar} {
1910
+ &:hover ${ActionBarRow},
1911
+ &:focus-within ${ActionBarRow} {
1749
1912
  opacity: 1;
1750
1913
  }
1751
1914
  &:hover ${TopOverlay},
1752
1915
  &:focus-within ${TopOverlay},
1753
- &:hover ${ActionBar},
1754
- &:focus-within ${ActionBar} {
1916
+ &:hover ${ActionBarRow},
1917
+ &:focus-within ${ActionBarRow} {
1755
1918
  pointer-events: auto;
1756
1919
  }
1757
1920
 
@@ -1762,7 +1925,7 @@ const Stage = styled(Panel)<{ $pinned: boolean }>`
1762
1925
  opacity: 1;
1763
1926
  pointer-events: auto;
1764
1927
  }
1765
- ${ActionBar} {
1928
+ ${ActionBarRow} {
1766
1929
  opacity: 1;
1767
1930
  pointer-events: auto;
1768
1931
  }
@@ -1781,57 +1944,6 @@ const Empty = styled.div`
1781
1944
  text-align: center;
1782
1945
  `;
1783
1946
 
1784
- const OverlayIconButton = styled.button<{ $active?: boolean }>`
1785
- position: relative;
1786
- width: 24px;
1787
- height: 24px;
1788
- display: flex;
1789
- align-items: center;
1790
- justify-content: center;
1791
- padding: 0;
1792
- /* Active-toggle fill (quality / tracking / PiP / fullscreen / custom actions
1793
- all share this button). Its own token so a consumer can recolour just the
1794
- action-row highlight without touching the general accent; defaults to
1795
- --kerbcast-accent, so the sidecar web page (which maps its accent onto that)
1796
- is unchanged. */
1797
- background: ${(p) =>
1798
- p.$active
1799
- ? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
1800
- : "rgba(0, 0, 0, 0.5)"};
1801
- border: 1px solid
1802
- ${(p) =>
1803
- p.$active
1804
- ? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
1805
- : "rgba(255, 255, 255, 0.3)"};
1806
- border-radius: 3px;
1807
- color: ${(p) => (p.$active ? "#000" : "#fff")};
1808
- cursor: pointer;
1809
- transition: background 0.12s, border-color 0.12s, color 0.12s;
1810
-
1811
- @media (hover: hover) {
1812
- &:hover {
1813
- background: ${(p) =>
1814
- p.$active
1815
- ? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
1816
- : "rgba(0, 0, 0, 0.7)"};
1817
- border-color: rgba(255, 255, 255, 0.6);
1818
- }
1819
- }
1820
-
1821
- &:focus-visible {
1822
- outline: 2px solid var(--kerbcast-accent, #00ff88);
1823
- outline-offset: 2px;
1824
- }
1825
-
1826
- &:disabled {
1827
- opacity: 0.4;
1828
- }
1829
-
1830
- @media (prefers-reduced-motion: reduce) {
1831
- transition: none;
1832
- }
1833
- `;
1834
-
1835
1947
  const StyledVideo = styled.video`
1836
1948
  position: absolute;
1837
1949
  inset: 0;