@ksp-gonogo/kerbcast-react 1.7.0 → 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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * FeedActionBar: the shared top-right action row rendered by BOTH
3
+ * `CameraFeed` and `KerbalFaceFeed`, so a single overflow engine serves every
4
+ * feed surface (part cams, crew faces, and any future host).
5
+ *
6
+ * Partition rule (issue #6 UI spec): stateful toggles (their at-rest state
7
+ * must stay visible, e.g. auto-track, the coming REC) are PRIMARY and always
8
+ * inline. Non-stateful actions (quality, PiP, fullscreen, plain consumer
9
+ * actions) are overflow-eligible: they collapse into a ⋮ menu ONLY once the
10
+ * row is genuinely crowded (total enabled >= 4 AND >= 2 non-stateful
11
+ * eligible): a single item never collapses, since that would cost a click
12
+ * to reach the one control that exists. Order is stable (an explicit `order`
13
+ * wins, otherwise entries keep the relative position their caller gave them)
14
+ * so chips don't reflow as buttons enable/disable around them.
15
+ *
16
+ * Close/remove is a THIRD category: `pinnedTrailing`. It is never
17
+ * overflow-eligible, never counted toward the overflow threshold, and always
18
+ * renders last (to the right of the ⋮ trigger itself), so dismissing a
19
+ * feed always stays a single, persistent, rightmost click. Bar order is:
20
+ * [stateful primaries] … [⋮ overflow, non-stateful] [pinned trailing close].
21
+ *
22
+ * Deliberately minimal: an entry is just (id, label, stateful?, order?,
23
+ * pinnedTrailing?, render). The interactive node itself (icon, aria
24
+ * attributes, onClick, any submenu it opens) is built by the caller
25
+ * (CameraFeed / KerbalFaceFeed) exactly as before; this component only
26
+ * decides WHERE it goes.
27
+ */
28
+ /**
29
+ * A consumer-supplied action rendered into a feed's action bar. Lets a host
30
+ * page (e.g. the sidecar's spotlight toggle) inject controls without the
31
+ * library knowing what they do.
32
+ */
33
+ export interface FeedAction {
34
+ /** Stable identity for the React key. */
35
+ id: string;
36
+ /** Accessible label; used for aria-label, the native tooltip, and the overflow-menu row's visible text. */
37
+ label: string;
38
+ /** Icon node, sized by the action bar (~14px). */
39
+ icon: React.ReactNode;
40
+ /** Toggle state: renders the button highlighted and sets aria-pressed. */
41
+ active?: boolean;
42
+ onClick: () => void;
43
+ /**
44
+ * Whether this action is a stateful toggle: stays PRIMARY / always
45
+ * inline so its at-rest state is visible, never collapses into the ⋮
46
+ * overflow. Defaults to `active !== undefined`. Override when a
47
+ * tri-state/menu-driven action's "active" isn't a plain boolean, or to force
48
+ * a boolean toggle to be overflow-eligible anyway.
49
+ */
50
+ stateful?: boolean;
51
+ /**
52
+ * Stable sort key for the action bar (lower sorts first). Actions without
53
+ * an explicit order keep their natural (array) position; this exists so a
54
+ * consumer can pin an action to a specific slot instead. Chips never
55
+ * reflow as buttons enable/disable; this is what keeps that stable.
56
+ */
57
+ order?: number;
58
+ /**
59
+ * Marks this as the bar's pinned-trailing (close/remove) slot: it is NEVER
60
+ * overflow-eligible, is excluded entirely from the overflow-threshold count
61
+ * (both the total and the non-stateful-eligible count), and always renders
62
+ * last (to the right of the ⋮ trigger), so dismissing a feed is always a
63
+ * single, persistent, rightmost click. `CameraFeed`'s `trailingActions`
64
+ * prop sets this automatically; a `KerbalFaceFeed`/`actions`-only consumer
65
+ * sets it explicitly on its close action.
66
+ */
67
+ pinnedTrailing?: boolean;
68
+ }
69
+ interface Partitionable {
70
+ stateful?: boolean;
71
+ order?: number;
72
+ pinnedTrailing?: boolean;
73
+ }
74
+ /**
75
+ * Sort a list of action-bar entries into (primary, overflow, pinnedTrailing).
76
+ * `entries` must already be filtered down to the currently-enabled set:
77
+ * this function has no notion of "disabled", only "present".
78
+ *
79
+ * `pinnedTrailing` entries (close/remove) are pulled out FIRST and excluded
80
+ * entirely from the overflow-threshold count. Both the total and the
81
+ * non-stateful-eligible count run only over the remaining entries, then
82
+ * always returned as their own group, to be rendered last (see
83
+ * `FeedActionBar`, which places them after the ⋮ trigger).
84
+ */
85
+ export declare function partitionActionBarEntries<T extends Partitionable>(entries: readonly T[]): {
86
+ primary: T[];
87
+ overflow: T[];
88
+ pinnedTrailing: T[];
89
+ };
90
+ /**
91
+ * One slot in the bar. `render` produces the actual interactive element
92
+ * (unchanged whether it lands inline or inside the overflow menu) so callers
93
+ * keep full control of refs / aria attributes / submenus; FeedActionBar only
94
+ * decides placement and, in the overflow menu, adds the visible label.
95
+ */
96
+ export interface FeedActionBarEntry {
97
+ id: string;
98
+ label: string;
99
+ stateful?: boolean;
100
+ order?: number;
101
+ pinnedTrailing?: boolean;
102
+ render: () => React.ReactNode;
103
+ }
104
+ /**
105
+ * Adapt a plain consumer/public `FeedAction` (id/label/icon/active/onClick,
106
+ * plus the optional stateful?/order?/pinnedTrailing? flags) into a bar entry,
107
+ * rendered as the standard icon button. `stateful` defaults to
108
+ * `active !== undefined` per the #6 spec (stateful toggles, meaning
109
+ * `active !== undefined`, stay primary).
110
+ */
111
+ export declare function feedActionToEntry(action: FeedAction): FeedActionBarEntry;
112
+ export interface FeedActionBarProps {
113
+ /** Non-partitioned content rendered before the partitioned entries (e.g. the camera step buttons). */
114
+ leading?: React.ReactNode;
115
+ entries: FeedActionBarEntry[];
116
+ /** Forwarded to the root row; lets a host `styled(FeedActionBar)` override layout (e.g. KerbalFaceFeed's smaller, always-visible bar). */
117
+ className?: string;
118
+ }
119
+ /**
120
+ * Shared top-right action row. Renders `leading`, then the partitioned
121
+ * entries: stateful ones always inline, non-stateful ones inline until the
122
+ * row is crowded enough to warrant a ⋮ overflow menu, then the pinned
123
+ * trailing (close/remove) entries: always last, to the right of the ⋮.
124
+ */
125
+ export declare function FeedActionBar({ leading, entries, className }: FeedActionBarProps): import("react").JSX.Element | null;
126
+ /** Icon-button shared by every action-bar entry (built-in or consumer). */
127
+ export declare const OverlayIconButton: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components").FastOmit<import("styled-components").FastOmit<import("react").DetailedHTMLProps<import("react").ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>, "$active"> & {
128
+ $active?: boolean;
129
+ }, never> & Partial<Pick<import("styled-components").FastOmit<import("react").DetailedHTMLProps<import("react").ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>, "$active"> & {
130
+ $active?: boolean;
131
+ }, never>>> & string;
132
+ export declare const ActionBarRow: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components").FastOmit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, never> & Partial<Pick<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, never>>> & string;
133
+ export {};
@@ -0,0 +1,225 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * FeedActionBar: the shared top-right action row rendered by BOTH
4
+ * `CameraFeed` and `KerbalFaceFeed`, so a single overflow engine serves every
5
+ * feed surface (part cams, crew faces, and any future host).
6
+ *
7
+ * Partition rule (issue #6 UI spec): stateful toggles (their at-rest state
8
+ * must stay visible, e.g. auto-track, the coming REC) are PRIMARY and always
9
+ * inline. Non-stateful actions (quality, PiP, fullscreen, plain consumer
10
+ * actions) are overflow-eligible: they collapse into a ⋮ menu ONLY once the
11
+ * row is genuinely crowded (total enabled >= 4 AND >= 2 non-stateful
12
+ * eligible): a single item never collapses, since that would cost a click
13
+ * to reach the one control that exists. Order is stable (an explicit `order`
14
+ * wins, otherwise entries keep the relative position their caller gave them)
15
+ * so chips don't reflow as buttons enable/disable around them.
16
+ *
17
+ * Close/remove is a THIRD category: `pinnedTrailing`. It is never
18
+ * overflow-eligible, never counted toward the overflow threshold, and always
19
+ * renders last (to the right of the ⋮ trigger itself), so dismissing a
20
+ * feed always stays a single, persistent, rightmost click. Bar order is:
21
+ * [stateful primaries] … [⋮ overflow, non-stateful] [pinned trailing close].
22
+ *
23
+ * Deliberately minimal: an entry is just (id, label, stateful?, order?,
24
+ * pinnedTrailing?, render). The interactive node itself (icon, aria
25
+ * attributes, onClick, any submenu it opens) is built by the caller
26
+ * (CameraFeed / KerbalFaceFeed) exactly as before; this component only
27
+ * decides WHERE it goes.
28
+ */
29
+ import { Fragment, useId } from "react";
30
+ import { createPortal } from "react-dom";
31
+ import styled from "styled-components";
32
+ import { usePortalMenu } from "./menuPositioning";
33
+ // ---------------------------------------------------------------------------
34
+ // Partition logic (pure, unit-tested independently of any rendering)
35
+ // ---------------------------------------------------------------------------
36
+ const OVERFLOW_MIN_TOTAL = 4;
37
+ const OVERFLOW_MIN_NON_STATEFUL = 2;
38
+ /**
39
+ * Sort a list of action-bar entries into (primary, overflow, pinnedTrailing).
40
+ * `entries` must already be filtered down to the currently-enabled set:
41
+ * this function has no notion of "disabled", only "present".
42
+ *
43
+ * `pinnedTrailing` entries (close/remove) are pulled out FIRST and excluded
44
+ * entirely from the overflow-threshold count. Both the total and the
45
+ * non-stateful-eligible count run only over the remaining entries, then
46
+ * always returned as their own group, to be rendered last (see
47
+ * `FeedActionBar`, which places them after the ⋮ trigger).
48
+ */
49
+ export function partitionActionBarEntries(entries) {
50
+ const sorted = [...entries].sort((a, b) => (a.order ?? Number.POSITIVE_INFINITY) - (b.order ?? Number.POSITIVE_INFINITY));
51
+ const pinnedTrailing = sorted.filter((e) => e.pinnedTrailing);
52
+ const overflowable = sorted.filter((e) => !e.pinnedTrailing);
53
+ const nonStateful = overflowable.filter((e) => !e.stateful);
54
+ const overflowActive = overflowable.length >= OVERFLOW_MIN_TOTAL && nonStateful.length >= OVERFLOW_MIN_NON_STATEFUL;
55
+ if (!overflowActive) {
56
+ return { primary: overflowable, overflow: [], pinnedTrailing };
57
+ }
58
+ return {
59
+ primary: overflowable.filter((e) => e.stateful),
60
+ overflow: nonStateful,
61
+ pinnedTrailing,
62
+ };
63
+ }
64
+ /**
65
+ * Adapt a plain consumer/public `FeedAction` (id/label/icon/active/onClick,
66
+ * plus the optional stateful?/order?/pinnedTrailing? flags) into a bar entry,
67
+ * rendered as the standard icon button. `stateful` defaults to
68
+ * `active !== undefined` per the #6 spec (stateful toggles, meaning
69
+ * `active !== undefined`, stay primary).
70
+ */
71
+ export function feedActionToEntry(action) {
72
+ return {
73
+ id: action.id,
74
+ label: action.label,
75
+ stateful: action.stateful ?? action.active !== undefined,
76
+ order: action.order,
77
+ pinnedTrailing: action.pinnedTrailing,
78
+ render: () => (_jsx(OverlayIconButton, { type: "button", "aria-label": action.label, "aria-pressed": action.active ?? undefined, title: action.label, "$active": action.active ?? false, onClick: action.onClick, children: action.icon }, action.id)),
79
+ };
80
+ }
81
+ // ---------------------------------------------------------------------------
82
+ // The bar itself
83
+ // ---------------------------------------------------------------------------
84
+ const OVERFLOW_MENU_MAX_WIDTH = 200;
85
+ const OVERFLOW_MENU_MAX_HEIGHT = 240;
86
+ /**
87
+ * Shared top-right action row. Renders `leading`, then the partitioned
88
+ * entries: stateful ones always inline, non-stateful ones inline until the
89
+ * row is crowded enough to warrant a ⋮ overflow menu, then the pinned
90
+ * trailing (close/remove) entries: always last, to the right of the ⋮.
91
+ */
92
+ export function FeedActionBar({ leading, entries, className }) {
93
+ const { primary, overflow, pinnedTrailing } = partitionActionBarEntries(entries);
94
+ const menuId = useId();
95
+ const overflowMenu = usePortalMenu({
96
+ maxWidth: OVERFLOW_MENU_MAX_WIDTH,
97
+ maxHeight: OVERFLOW_MENU_MAX_HEIGHT,
98
+ align: "end",
99
+ });
100
+ const closeOverflowMenu = overflowMenu.close;
101
+ if (!leading && entries.length === 0)
102
+ return null;
103
+ return (_jsxs(ActionBarRow, { className: className, children: [leading, primary.map((e) => (_jsx(Fragment, { children: e.render() }, e.id))), overflow.length > 0 && (_jsx(OverlayIconButton, { ref: overflowMenu.triggerRef, type: "button", "aria-label": "More actions", "aria-haspopup": "menu", "aria-expanded": overflowMenu.open, "aria-controls": menuId, title: "More actions", "$active": overflowMenu.open, onClick: overflowMenu.toggle, children: _jsx(MoreIcon, {}) })), overflow.length > 0 &&
104
+ overflowMenu.open &&
105
+ overflowMenu.position &&
106
+ createPortal(_jsx(OverflowMenu, { ref: overflowMenu.menuRef, id: menuId, role: "menu", "aria-label": "More actions", style: overflowMenu.position, children: overflow.map((e) => (
107
+ // The wrapping row's onClick fires in the BUBBLE phase, after
108
+ // the entry's own button click handler, so this closes the
109
+ // menu once the action has actually fired rather than
110
+ // pre-empting it.
111
+ _jsxs(OverflowMenuItem, { onClick: closeOverflowMenu, children: [e.render(), _jsx(OverflowMenuItemLabel, { "aria-hidden": "true", children: e.label })] }, e.id))) }), document.body), pinnedTrailing.map((e) => (_jsx(Fragment, { children: e.render() }, e.id)))] }));
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // Icons
115
+ // ---------------------------------------------------------------------------
116
+ /* Overflow trigger: vertical ellipsis (three dots). */
117
+ function MoreIcon() {
118
+ return (_jsxs("svg", { viewBox: "0 0 16 16", width: 14, height: 14, fill: "currentColor", stroke: "none", "aria-hidden": "true", children: [_jsx("circle", { cx: "8", cy: "3", r: "1.4" }), _jsx("circle", { cx: "8", cy: "8", r: "1.4" }), _jsx("circle", { cx: "8", cy: "13", r: "1.4" })] }));
119
+ }
120
+ // ---------------------------------------------------------------------------
121
+ // Styled components
122
+ // ---------------------------------------------------------------------------
123
+ /** Icon-button shared by every action-bar entry (built-in or consumer). */
124
+ export const OverlayIconButton = styled.button `
125
+ position: relative;
126
+ width: 24px;
127
+ height: 24px;
128
+ display: flex;
129
+ align-items: center;
130
+ justify-content: center;
131
+ padding: 0;
132
+ /* Active-toggle fill (quality / tracking / PiP / fullscreen / custom actions
133
+ all share this button). Its own token so a consumer can recolour just the
134
+ action-row highlight without touching the general accent; defaults to
135
+ --kerbcast-accent, so the sidecar web page (which maps its accent onto that)
136
+ is unchanged. */
137
+ background: ${(p) => p.$active
138
+ ? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
139
+ : "rgba(0, 0, 0, 0.5)"};
140
+ border: 1px solid
141
+ ${(p) => p.$active
142
+ ? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
143
+ : "rgba(255, 255, 255, 0.3)"};
144
+ border-radius: 3px;
145
+ color: ${(p) => (p.$active ? "#000" : "#fff")};
146
+ cursor: pointer;
147
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
148
+
149
+ @media (hover: hover) {
150
+ &:hover {
151
+ background: ${(p) => p.$active
152
+ ? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
153
+ : "rgba(0, 0, 0, 0.7)"};
154
+ border-color: rgba(255, 255, 255, 0.6);
155
+ }
156
+ }
157
+
158
+ &:focus-visible {
159
+ outline: 2px solid var(--kerbcast-accent, #00ff88);
160
+ outline-offset: 2px;
161
+ }
162
+
163
+ &:disabled {
164
+ opacity: 0.4;
165
+ }
166
+
167
+ @media (prefers-reduced-motion: reduce) {
168
+ transition: none;
169
+ }
170
+ `;
171
+ /* Top-right cluster of overlay controls (custom actions + built-ins + overflow). */
172
+ export const ActionBarRow = styled.div `
173
+ position: absolute;
174
+ top: 6px;
175
+ right: 8px;
176
+ z-index: 3;
177
+ display: flex;
178
+ align-items: center;
179
+ gap: 4px;
180
+ opacity: 0;
181
+ pointer-events: none;
182
+ transition: opacity 0.15s;
183
+
184
+ @media (prefers-reduced-motion: reduce) {
185
+ transition: none;
186
+ }
187
+ `;
188
+ /* The ⋮ overflow dropdown: same portal/anchor contract as CameraFeed's other
189
+ menus (fixed position set inline from the trigger rect) so tile clipping
190
+ cannot cut it off. */
191
+ const OverflowMenu = styled.div `
192
+ position: fixed;
193
+ z-index: 1000;
194
+ min-width: 140px;
195
+ max-width: min(200px, calc(100vw - 16px));
196
+ max-height: min(40vh, 240px);
197
+ display: flex;
198
+ flex-direction: column;
199
+ padding: 4px;
200
+ gap: 2px;
201
+ background: rgba(0, 0, 0, 0.85);
202
+ border: 1px solid rgba(255, 255, 255, 0.3);
203
+ border-radius: 4px;
204
+ overflow-x: hidden;
205
+ overflow-y: auto;
206
+ `;
207
+ const OverflowMenuItem = styled.div `
208
+ display: flex;
209
+ align-items: center;
210
+ gap: 8px;
211
+ padding: 2px 4px;
212
+ border-radius: 3px;
213
+
214
+ @media (hover: hover) {
215
+ &:hover {
216
+ background: rgba(255, 255, 255, 0.1);
217
+ }
218
+ }
219
+ `;
220
+ const OverflowMenuItemLabel = styled.span `
221
+ font-size: 11px;
222
+ letter-spacing: 0.04em;
223
+ color: #fff;
224
+ white-space: nowrap;
225
+ `;
@@ -16,7 +16,7 @@
16
16
  * wrapper supplies one, so declarative controls compose the same way they do
17
17
  * on `CameraFeed`.
18
18
  */
19
- import { FeedAction, CameraStreamHook } from "./CameraFeed";
19
+ import { CameraStreamHook, FeedAction } from "./CameraFeed";
20
20
  /**
21
21
  * Coarse presentation state a wrapper reacts to for its own fallback/badge:
22
22
  * - `live`: a stream is bound and playing.
@@ -19,6 +19,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
19
19
  */
20
20
  import { useEffect, useRef } from "react";
21
21
  import styled from "styled-components";
22
+ import { FeedActionBar, feedActionToEntry } from "./FeedActionBar";
22
23
  import { useKerbcastStream } from "./hooks/useKerbcastStream";
23
24
  import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
24
25
  import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
@@ -59,26 +60,18 @@ const OverlayLayer = styled.div `
59
60
  position: absolute;
60
61
  inset: 0;
61
62
  `;
62
- const ActionBar = styled.div `
63
- position: absolute;
63
+ /*
64
+ * The shared FeedActionBar is hover-revealed by default (CameraFeed's larger
65
+ * frame fades it in on hover/focus). A tiny face tile has no such affordance,
66
+ * so this override keeps the original always-visible, corner-hugging look;
67
+ * layout/button styling (flex row, gap, the shared OverlayIconButton) still
68
+ * comes from FeedActionBar itself.
69
+ */
70
+ const KerbalActionBarRow = styled(FeedActionBar) `
64
71
  top: 4px;
65
72
  right: 4px;
66
- display: flex;
67
- gap: 4px;
68
- z-index: 2;
69
- `;
70
- const ActionButton = styled.button `
71
- display: inline-flex;
72
- align-items: center;
73
- justify-content: center;
74
- width: 22px;
75
- height: 22px;
76
- padding: 0;
77
- border: none;
78
- border-radius: 3px;
79
- cursor: pointer;
80
- color: #fff;
81
- background: ${(p) => (p.$active ? "rgba(255,255,255,0.28)" : "rgba(0,0,0,0.45)")};
73
+ opacity: 1;
74
+ pointer-events: auto;
82
75
  `;
83
76
  export function KerbalFaceFeed({ flightId, size, reportSize = true, actions, showActions = true, children, onFeedStateChange, showStandby = true, useStream, }) {
84
77
  // Match CameraFeed: the built-in hook (which acquires/releases the
@@ -114,5 +107,5 @@ export function KerbalFaceFeed({ flightId, size, reportSize = true, actions, sho
114
107
  useEffect(() => {
115
108
  notify.current?.(state);
116
109
  }, [state]);
117
- return (_jsxs(Frame, { ref: frameRef, "$size": size, "data-testid": "kerbal-face-feed", "data-feed-state": state, children: [_jsx(Face, { ref: videoRef, autoPlay: true, playsInline: true, muted: true, controls: false }), state === "destroyed" && _jsx(DestroyedScrim, {}), showStandby && state !== "live" && (_jsx(StandbyLayer, { children: _jsx(StandbyIcon, { size: Math.round((size ?? 96) * 0.32) }) })), showActions && actions && actions.length > 0 && (_jsx(ActionBar, { children: actions.map((a) => (_jsx(ActionButton, { type: "button", "aria-label": a.label, title: a.label, "aria-pressed": a.active, "$active": a.active, onClick: a.onClick, children: a.icon }, a.id))) })), children && _jsx(OverlayLayer, { children: children })] }));
110
+ return (_jsxs(Frame, { ref: frameRef, "$size": size, "data-testid": "kerbal-face-feed", "data-feed-state": state, children: [_jsx(Face, { ref: videoRef, autoPlay: true, playsInline: true, muted: true, controls: false }), state === "destroyed" && _jsx(DestroyedScrim, {}), showStandby && state !== "live" && (_jsx(StandbyLayer, { children: _jsx(StandbyIcon, { size: Math.round((size ?? 96) * 0.32) }) })), showActions && actions && actions.length > 0 && (_jsx(KerbalActionBarRow, { entries: actions.map(feedActionToEntry) })), children && _jsx(OverlayLayer, { children: children })] }));
118
111
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * RecordingsTray: a presentational drawer over the client-side recordings
3
+ * store (`useRecordings()`). Lists finished single clips and grouped SETS,
4
+ * with a play/preview, a download, and a discard per clip; a group also gets
5
+ * a download-all + a discard-set. Recording is client-local and in-memory (no
6
+ * sidecar, no server state), so this is where an operator saves a clip before
7
+ * a page reload discards it. The tray shows a quiet reminder of that.
8
+ *
9
+ * Deliberately dumb: it owns no recording logic, only formatting and the
10
+ * per-clip object-URL lifecycle (create on first render, revoke on discard or
11
+ * unmount). Starting/stopping recordings is CameraFeed's REC control (or a
12
+ * host's own REC+ grouped flow); this component only ever reads the store and
13
+ * calls its discard/discardGroup actions.
14
+ *
15
+ * Every `RecordingHandle` (standalone or a grouped member) carries its own
16
+ * `flightId`, so camera names come straight off the clip, with no dependency
17
+ * on the store's transient `active` list. That matters because the tray
18
+ * commonly mounts after a recording has already finished (its `active` entry
19
+ * long gone by then). A host supplies the flightId -> label step via
20
+ * `cameraLabel` (e.g. `buildCameraLabeler(useKerbcastCameras())`, the same
21
+ * labeller CameraFeed uses) so this stays decoupled from any particular
22
+ * camera registry; omit it for a plain "Camera <flightId>" fallback.
23
+ */
24
+ export interface RecordingsTrayProps {
25
+ /**
26
+ * Resolve a flightId to a display label for a clip's camera name. Omit for
27
+ * a plain "Camera <flightId>" fallback.
28
+ */
29
+ cameraLabel?: (flightId: number) => string;
30
+ /** Forwarded to the root panel; lets a host lay out/size the tray. */
31
+ className?: string;
32
+ }
33
+ /**
34
+ * The shared drawer. Requires a `KerbcastProvider` ancestor (it calls
35
+ * `useRecordings()` directly, same requirement as `CameraFeed`).
36
+ */
37
+ export declare function RecordingsTray({ cameraLabel, className }: RecordingsTrayProps): import("react").JSX.Element;