@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.0 - 2026-07-28
4
+
5
+ ### Added
6
+
7
+ - `useRecordings`: a reactive recordings store over the SDK's
8
+ `client.recording` controller. Exposes finished standalone clips
9
+ (`recordings`), synchronised sets (`groups`), and in-progress recordings
10
+ (`active`, for a REC dot + elapsed timer), plus `start` / `stop` /
11
+ `startGroup` / `stopGroup` / `discard` / `discardGroup` / `isRecording`. This
12
+ is the headless data layer the recordings tray + REC controls sit on; it owns
13
+ no chrome and keeps grouped clips out of the singles list
14
+ - `RecordingsStore` and `ActiveRecordingInfo` types
15
+
3
16
  ## 1.7.0 - 2026-07-24
4
17
 
5
18
  Crew face cameras, auto-resolution, and camera targeting.
@@ -1,20 +1,11 @@
1
1
  import type { CameraState, KerbcastClient } from "@ksp-gonogo/kerbcast";
2
+ import { type FeedAction } from "./FeedActionBar";
2
3
  /**
3
- * A consumer-supplied action rendered into the feed's top-right action bar.
4
- * Lets a host page (e.g. the sidecar's spotlight toggle) inject controls
5
- * without the library knowing what they do.
4
+ * The consumer-supplied action shape, and the shared top-right action bar
5
+ * both `CameraFeed` and `KerbalFaceFeed` render, live in `FeedActionBar`.
6
+ * Re-exported here since existing imports reference `./CameraFeed`.
6
7
  */
7
- export interface FeedAction {
8
- /** Stable identity for the React key. */
9
- id: string;
10
- /** Accessible label; used for aria-label and the native tooltip. */
11
- label: string;
12
- /** Icon node, sized by the action bar (~14px). */
13
- icon: React.ReactNode;
14
- /** Toggle state: renders the button highlighted and sets aria-pressed. */
15
- active?: boolean;
16
- onClick: () => void;
17
- }
8
+ export type { FeedAction } from "./FeedActionBar";
18
9
  /**
19
10
  * A hook that yields the live `MediaStream` for a resolved flightId. Must be
20
11
  * a stable reference (the same function identity every render) and called
@@ -125,6 +116,29 @@ export interface CameraFeedProps {
125
116
  * the gimbal + FoV). Default false.
126
117
  */
127
118
  enableTracking?: boolean;
119
+ /**
120
+ * Show a built-in REC control in the action bar: a STATEFUL toggle (never
121
+ * overflow-eligible, per the #6 spec) that starts/stops a client-side
122
+ * recording of this feed via `useRecordings()`. Idle -> click starts
123
+ * (`start(flightId)`); active shows a red dot + a live elapsed timer on the
124
+ * tile -> click stops (`stop(recordingId)`) and the clip lands in the
125
+ * recordings store. Recording is purely client-local (this browser's own
126
+ * MediaRecorder, nothing server-authoritative) and read from the store
127
+ * (`isRecording(flightId)`), so it stays correct if toggled elsewhere (e.g.
128
+ * a REC+ grouped recording). Default false.
129
+ */
130
+ enableRecording?: boolean;
131
+ /**
132
+ * Record this feed at the operator's full render size for the duration of
133
+ * the built-in REC control's recording (overrides only the display-size
134
+ * demand; still yields to the adaptive framerate shed). Off by default.
135
+ * Threaded straight into `start(flightId, { forceFullResolution })`; has
136
+ * no effect unless `enableRecording` is also set. While the feed is armed
137
+ * (force sent, waiting for the resolution bump) the tile shows an
138
+ * "ARMING" pill under the title in place of the REC badge, then switches
139
+ * to the normal REC badge + elapsed timer once recording actually starts.
140
+ */
141
+ recordFullResolution?: boolean;
128
142
  /**
129
143
  * Consumer-injected action buttons, rendered left of the built-in
130
144
  * fullscreen/PiP controls in the top-right action bar.
@@ -132,8 +146,10 @@ export interface CameraFeedProps {
132
146
  actions?: FeedAction[];
133
147
  /**
134
148
  * Consumer-injected action buttons rendered at the far end of the action
135
- * bar, right of the built-in controls — the natural home for a close/remove
136
- * button so it sits in the corner.
149
+ * bar: the natural home for a close/remove button. Always pinned trailing
150
+ * (never overflow-eligible, never counted toward the overflow threshold),
151
+ * so it sits in the corner as a persistent, single-click control, right of
152
+ * the ⋮ overflow trigger when one is present.
137
153
  */
138
154
  trailingActions?: FeedAction[];
139
155
  /**
@@ -1,16 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { PanZoomController, QualityPreset, TrackMode } from "@ksp-gonogo/kerbcast";
3
- import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from "react";
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";
11
13
  import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
12
14
  import { StandbyIcon } from "./StandbyIcon";
13
15
  import { isCameraDestroyed } from "./lifecycle";
16
+ import { usePortalMenu } from "./menuPositioning";
17
+ import { formatElapsed, nowMs } from "./timing";
14
18
  // ---------------------------------------------------------------------------
15
19
  // Tuning constants
16
20
  // ---------------------------------------------------------------------------
@@ -19,97 +23,6 @@ const MENU_MAX_WIDTH = 260; // matches CameraMenu's CSS cap
19
23
  const MENU_MAX_HEIGHT = 300; // matches CameraMenu's min(40vh, 300px) cap
20
24
  const QUALITY_MENU_MAX_WIDTH = 220; // matches QualityMenu's CSS cap
21
25
  const QUALITY_MENU_MAX_HEIGHT = 220; // matches QualityMenu's min(40vh, 220px) cap
22
- const MENU_GAP = 4; // trigger-to-menu spacing
23
- const MENU_EDGE = 8; // minimum inset from the viewport edge
24
- /*
25
- * Fixed-position style for a portaled menu, anchored to its trigger button.
26
- * Opens downward by default; flips above the trigger when there is not
27
- * enough room below but there is above, otherwise clamps to the viewport.
28
- */
29
- function computeMenuPosition(trigger, anchor) {
30
- const rect = trigger.getBoundingClientRect();
31
- const vw = window.innerWidth;
32
- const vh = window.innerHeight;
33
- const width = Math.min(anchor.maxWidth, vw - 2 * MENU_EDGE);
34
- /* Inline offset from the aligned viewport edge, clamped so a full-width
35
- menu still fits inside the opposite edge. */
36
- const inset = Math.max(MENU_EDGE, vw - width - MENU_EDGE);
37
- const inline = anchor.align === "start"
38
- ? { left: Math.min(Math.max(rect.left, MENU_EDGE), inset) }
39
- : { right: Math.min(Math.max(vw - rect.right, MENU_EDGE), inset) };
40
- const menuH = Math.min(0.4 * vh, anchor.maxHeight);
41
- const fitsBelow = rect.bottom + MENU_GAP + menuH <= vh - MENU_EDGE;
42
- const fitsAbove = rect.top - MENU_GAP - menuH >= MENU_EDGE;
43
- if (!fitsBelow && fitsAbove) {
44
- return { ...inline, bottom: vh - rect.top + MENU_GAP };
45
- }
46
- const top = Math.min(rect.bottom + MENU_GAP, Math.max(MENU_EDGE, vh - MENU_EDGE - menuH));
47
- return { ...inline, top };
48
- }
49
- /*
50
- * Shared behaviour for a dropdown portaled to document.body: fixed position
51
- * computed from the trigger's rect (so tile overflow cannot clip it),
52
- * re-anchored on window resize/scroll while open, Escape to close with focus
53
- * returned to the trigger, and portal-aware outside-pointer-down dismissal
54
- * (menuRef points at the portaled menu, so clicks inside it stay "inside").
55
- * One menu at a time falls out of the dismissal: opening another menu's
56
- * trigger is an outside press for this one.
57
- */
58
- function usePortalMenu({ maxWidth, maxHeight, align }) {
59
- const [open, setOpen] = useState(false);
60
- const [position, setPosition] = useState(null);
61
- const menuRef = useRef(null);
62
- const triggerRef = useRef(null);
63
- useEffect(() => {
64
- if (!open) {
65
- setPosition(null);
66
- return;
67
- }
68
- const update = () => {
69
- const trigger = triggerRef.current;
70
- if (trigger) {
71
- setPosition(computeMenuPosition(trigger, { maxWidth, maxHeight, align }));
72
- }
73
- };
74
- update();
75
- window.addEventListener("resize", update);
76
- window.addEventListener("scroll", update, true);
77
- return () => {
78
- window.removeEventListener("resize", update);
79
- window.removeEventListener("scroll", update, true);
80
- };
81
- }, [open, maxWidth, maxHeight, align]);
82
- useEffect(() => {
83
- if (!open)
84
- return;
85
- const onKeyDown = (e) => {
86
- if (e.key === "Escape") {
87
- e.stopPropagation();
88
- setOpen(false);
89
- triggerRef.current?.focus();
90
- }
91
- };
92
- const onPointerDown = (e) => {
93
- if (!menuRef.current?.contains(e.target) &&
94
- !triggerRef.current?.contains(e.target)) {
95
- setOpen(false);
96
- }
97
- };
98
- document.addEventListener("keydown", onKeyDown);
99
- document.addEventListener("pointerdown", onPointerDown);
100
- return () => {
101
- document.removeEventListener("keydown", onKeyDown);
102
- document.removeEventListener("pointerdown", onPointerDown);
103
- };
104
- }, [open]);
105
- const toggle = useCallback(() => setOpen((v) => !v), []);
106
- /** Close after an item pick, returning focus to the trigger. */
107
- const close = useCallback(() => {
108
- setOpen(false);
109
- triggerRef.current?.focus();
110
- }, []);
111
- return { open, toggle, close, position, menuRef, triggerRef };
112
- }
113
26
  /*
114
27
  * Viewer quality presets, in menu order. Scales mirror the sidecar's
115
28
  * QualityPreset mapping (fractions of the camera's operator render size);
@@ -146,8 +59,11 @@ function exitFullscreen() {
146
59
  // ---------------------------------------------------------------------------
147
60
  // Inner component (reads from context)
148
61
  // ---------------------------------------------------------------------------
149
- 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, actions, trailingActions, showActions = true, 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) {
150
63
  const client = useKerbcastClient();
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();
151
67
  // The selectable set: every live camera, optionally narrowed by the host's
152
68
  // cameraFilter (e.g. a part-grid tile offering only part cams). Drives the
153
69
  // picker menu, the stepper, and the auto-latch fallback so none can land on
@@ -343,7 +259,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
343
259
  onSelectCamera?.(nextId);
344
260
  }, [cameras, currentIndex, onSelectCamera]);
345
261
  // -------------------------------------------------------------------------
346
- // PanZoomController -- one per displayed camera
262
+ // PanZoomController: one per displayed camera
347
263
  // -------------------------------------------------------------------------
348
264
  const controllerRef = useRef(null);
349
265
  const [sliderFov, setSliderFov] = useState(60);
@@ -358,8 +274,8 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
358
274
  controllerRef.current?.dispose();
359
275
  controllerRef.current = ctrl;
360
276
  const unsubSlider = ctrl.onSliderFov(setSliderFov);
361
- // Seed the slider from the controller's initial (0) -- will be overwritten
362
- // 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.
363
279
  setSliderFov(ctrl.sliderFov);
364
280
  return () => {
365
281
  unsubSlider();
@@ -524,7 +440,54 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
524
440
  void client.setTrackTarget(flightId, next);
525
441
  closeTrackingMenu();
526
442
  }, [client, flightId, trackMode, closeTrackingMenu]);
527
- 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 &&
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 &&
528
491
  hasCameras &&
529
492
  cameraMenu.position &&
530
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: () => {
@@ -532,21 +495,105 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
532
495
  cameraMenu.close();
533
496
  }, children: [cameraLabel(c), " (", c.vesselName, ")", isCameraDestroyed(c) ? " - signal lost" : ""] }, c.flightId))) }), document.body), showDebugInfo &&
534
497
  (camera ? (_jsxs(TopMeta, { children: [camera.vesselName, " \u00B7 ", camera.renderWidth, "\u00D7", camera.renderHeight, bitrateLabel, adaptiveLabel] })) : (_jsx(TopMeta, { children: "no cameras on this vessel" })))] }));
535
- 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));
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
+ ];
536
584
  const builtInActions = flightId !== null &&
537
- (pipAvailable || fullscreenAvailable || qualityAvailable || trackingAvailable);
585
+ (pipAvailable ||
586
+ fullscreenAvailable ||
587
+ qualityAvailable ||
588
+ trackingAvailable ||
589
+ enableRecording);
538
590
  const hasActionBar = showActions &&
539
591
  (hasCameras ||
540
592
  (actions && actions.length > 0) ||
541
593
  (trailingActions && trailingActions.length > 0) ||
542
594
  builtInActions);
543
- 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
544
- ? "Quality (throttled by adaptive performance)"
545
- : "Quality", "$active": qualityMenu.open, onClick: qualityMenu.toggle, children: [_jsx(QualityIcon, {}), qualityThrottled && _jsx(ThrottledDot, { "aria-hidden": "true" })] })), trackingAvailable && (_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: tracking
546
- ? trackMode === TrackMode.Target
547
- ? "Auto-tracking target"
548
- : "Auto-tracking active vessel"
549
- : "Auto-track a vessel", "$active": tracking || trackingMenu.open, onClick: trackingMenu.toggle, children: _jsx(CrosshairIcon, {}) })), 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;
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;
550
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 &&
551
598
  qualityMenu.open &&
552
599
  qualityMenu.position &&
@@ -555,7 +602,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
555
602
  trackingMenu.open &&
556
603
  trackingMenu.position &&
557
604
  camera &&
558
- 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"] })] })), 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) => {
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) => {
559
606
  if (e.detail === 0)
560
607
  controllerRef.current?.nudgeZoom(-1);
561
608
  }, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
@@ -626,6 +673,10 @@ function StaleIcon() {
626
673
  function PictureInPictureIcon() {
627
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" })] }));
628
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
+ }
629
680
  // ---------------------------------------------------------------------------
630
681
  // Styled components
631
682
  // ---------------------------------------------------------------------------
@@ -933,21 +984,107 @@ const ThrottledDot = styled.span `
933
984
  background: #ffb347;
934
985
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6);
935
986
  `;
936
- /* Top-right cluster of overlay controls (custom actions + fullscreen/PiP). */
937
- 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 `
938
1005
  position: absolute;
939
- top: 6px;
940
- right: 8px;
941
- 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;
942
1011
  display: flex;
943
1012
  align-items: center;
944
- gap: 4px;
945
- 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);
946
1023
  pointer-events: none;
947
- 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;
948
1031
 
949
- @media (prefers-reduced-motion: reduce) {
950
- 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
+ }
951
1088
  }
952
1089
  `;
953
1090
  /*
@@ -980,14 +1117,14 @@ const Stage = styled(Panel) `
980
1117
  &:focus-within ${ZoomControlsWrap},
981
1118
  &:hover ${PanControl},
982
1119
  &:focus-within ${PanControl},
983
- &:hover ${ActionBar},
984
- &:focus-within ${ActionBar} {
1120
+ &:hover ${ActionBarRow},
1121
+ &:focus-within ${ActionBarRow} {
985
1122
  opacity: 1;
986
1123
  }
987
1124
  &:hover ${TopOverlay},
988
1125
  &:focus-within ${TopOverlay},
989
- &:hover ${ActionBar},
990
- &:focus-within ${ActionBar} {
1126
+ &:hover ${ActionBarRow},
1127
+ &:focus-within ${ActionBarRow} {
991
1128
  pointer-events: auto;
992
1129
  }
993
1130
 
@@ -997,7 +1134,7 @@ const Stage = styled(Panel) `
997
1134
  opacity: 1;
998
1135
  pointer-events: auto;
999
1136
  }
1000
- ${ActionBar} {
1137
+ ${ActionBarRow} {
1001
1138
  opacity: 1;
1002
1139
  pointer-events: auto;
1003
1140
  }
@@ -1014,53 +1151,6 @@ const Empty = styled.div `
1014
1151
  padding: 1rem;
1015
1152
  text-align: center;
1016
1153
  `;
1017
- const OverlayIconButton = styled.button `
1018
- position: relative;
1019
- width: 24px;
1020
- height: 24px;
1021
- display: flex;
1022
- align-items: center;
1023
- justify-content: center;
1024
- padding: 0;
1025
- /* Active-toggle fill (quality / tracking / PiP / fullscreen / custom actions
1026
- all share this button). Its own token so a consumer can recolour just the
1027
- action-row highlight without touching the general accent; defaults to
1028
- --kerbcast-accent, so the sidecar web page (which maps its accent onto that)
1029
- is unchanged. */
1030
- background: ${(p) => p.$active
1031
- ? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
1032
- : "rgba(0, 0, 0, 0.5)"};
1033
- border: 1px solid
1034
- ${(p) => p.$active
1035
- ? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
1036
- : "rgba(255, 255, 255, 0.3)"};
1037
- border-radius: 3px;
1038
- color: ${(p) => (p.$active ? "#000" : "#fff")};
1039
- cursor: pointer;
1040
- transition: background 0.12s, border-color 0.12s, color 0.12s;
1041
-
1042
- @media (hover: hover) {
1043
- &:hover {
1044
- background: ${(p) => p.$active
1045
- ? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
1046
- : "rgba(0, 0, 0, 0.7)"};
1047
- border-color: rgba(255, 255, 255, 0.6);
1048
- }
1049
- }
1050
-
1051
- &:focus-visible {
1052
- outline: 2px solid var(--kerbcast-accent, #00ff88);
1053
- outline-offset: 2px;
1054
- }
1055
-
1056
- &:disabled {
1057
- opacity: 0.4;
1058
- }
1059
-
1060
- @media (prefers-reduced-motion: reduce) {
1061
- transition: none;
1062
- }
1063
- `;
1064
1154
  const StyledVideo = styled.video `
1065
1155
  position: absolute;
1066
1156
  inset: 0;