@ksp-gonogo/kerbcast-react 1.6.3 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +19 -0
  3. package/dist/CameraFeed.d.ts +63 -19
  4. package/dist/CameraFeed.js +329 -202
  5. package/dist/FeedActionBar.d.ts +133 -0
  6. package/dist/FeedActionBar.js +225 -0
  7. package/dist/KerbalFaceFeed.d.ts +82 -0
  8. package/dist/KerbalFaceFeed.js +111 -0
  9. package/dist/RecordingsTray.d.ts +37 -0
  10. package/dist/RecordingsTray.js +279 -0
  11. package/dist/context.d.ts +27 -1
  12. package/dist/context.js +68 -2
  13. package/dist/hooks/useRecordings.d.ts +35 -0
  14. package/dist/hooks/useRecordings.js +33 -0
  15. package/dist/hooks/useReportDisplaySize.d.ts +23 -0
  16. package/dist/hooks/useReportDisplaySize.js +71 -0
  17. package/dist/index.d.ts +12 -2
  18. package/dist/index.js +13 -1
  19. package/dist/menuPositioning.d.ts +20 -0
  20. package/dist/menuPositioning.js +98 -0
  21. package/dist/timing.d.ts +4 -0
  22. package/dist/timing.js +19 -0
  23. package/package.json +2 -2
  24. package/src/CameraFeed.test.tsx +719 -10
  25. package/src/CameraFeed.tsx +586 -334
  26. package/src/FeedActionBar.test.ts +196 -0
  27. package/src/FeedActionBar.test.tsx +215 -0
  28. package/src/FeedActionBar.tsx +391 -0
  29. package/src/KerbalFaceFeed.test.tsx +203 -0
  30. package/src/KerbalFaceFeed.tsx +214 -0
  31. package/src/RecordingsTray.test.tsx +288 -0
  32. package/src/RecordingsTray.tsx +475 -0
  33. package/src/context.test.tsx +94 -0
  34. package/src/context.tsx +98 -1
  35. package/src/hooks/useRecordings.sharedState.test.tsx +117 -0
  36. package/src/hooks/useRecordings.test.tsx +217 -0
  37. package/src/hooks/useRecordings.ts +110 -0
  38. package/src/hooks/useReportDisplaySize.test.tsx +166 -0
  39. package/src/hooks/useReportDisplaySize.ts +91 -0
  40. package/src/index.ts +27 -1
  41. package/src/menuPositioning.ts +126 -0
  42. package/src/timing.test.ts +34 -0
  43. package/src/timing.ts +21 -0
@@ -0,0 +1,279 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
3
+ import styled from "styled-components";
4
+ import { useRecordings } from "./hooks/useRecordings";
5
+ /**
6
+ * The shared drawer. Requires a `KerbcastProvider` ancestor (it calls
7
+ * `useRecordings()` directly, same requirement as `CameraFeed`).
8
+ */
9
+ export function RecordingsTray({ cameraLabel, className }) {
10
+ const store = useRecordings();
11
+ const labelFor = useCallback((flightId) => (cameraLabel ? cameraLabel(flightId) : `Camera ${flightId}`), [cameraLabel]);
12
+ const allClips = useMemo(() => [...store.recordings, ...store.groups.flatMap((g) => g.recordings)], [store.recordings, store.groups]);
13
+ const objectUrls = useClipObjectUrls(allClips);
14
+ const hasAny = store.recordings.length > 0 || store.groups.length > 0;
15
+ return (_jsx(Tray, { className: className, "aria-label": "Recordings", children: !hasAny ? (_jsx(Empty, { children: "No recordings yet. Press REC on a feed to start one." })) : (_jsxs(_Fragment, { children: [_jsx(Notice, { children: "Unsaved clips are lost on reload. Download what you want to keep." }), _jsxs(ClipList, { children: [store.recordings.map((rec) => (_jsx(ClipRow, { rec: rec, label: labelFor(rec.flightId), url: objectUrls.get(rec.recordingId), onDiscard: () => store.discard(rec.recordingId) }, rec.recordingId))), store.groups.map((group) => (_jsx(GroupItem, { group: group, labelFor: labelFor, objectUrls: objectUrls, onDiscardGroup: () => store.discardGroup(group.groupId) }, group.groupId)))] })] })) }));
16
+ }
17
+ function ClipRow({ rec, label, url, onDiscard }) {
18
+ const utRange = rec.utStart != null && rec.utEnd != null
19
+ ? `${formatUt(rec.utStart)} - ${formatUt(rec.utEnd)}`
20
+ : "no UT (out of flight)";
21
+ return (_jsxs(Clip, { children: [url ? (_jsx(ClipPreview, { controls: true, preload: "metadata", src: url, "aria-label": `Preview: ${label}` })) : (_jsx(ClipPreviewPlaceholder, { "aria-hidden": "true" })), _jsxs(ClipMeta, { children: [_jsx(ClipName, { children: label }), _jsx(ClipDetail, { children: utRange }), _jsxs(ClipDetail, { children: [formatDuration(rec.durationMs), " \u00B7 ", formatBytes(rec.byteSize)] })] }), _jsxs(ClipActions, { children: [url && (_jsx(TrayLink, { href: url, download: clipFileName(rec, label), children: "Download" })), onDiscard && (_jsx(TrayButton, { type: "button", "$danger": true, onClick: onDiscard, children: "Discard" }))] })] }));
22
+ }
23
+ function GroupItem({ group, labelFor, objectUrls, onDiscardGroup }) {
24
+ return (_jsxs(GroupCard, { "aria-label": `Grouped recording, ${group.recordings.length} feeds`, children: [_jsxs(GroupHeader, { children: [_jsxs(GroupHeaderText, { children: [_jsxs(GroupTitle, { children: ["Grouped recording \u00B7 ", group.recordings.length, " feeds"] }), group.commonUtWindow && (_jsxs(GroupMeta, { children: ["common UT ", formatUt(group.commonUtWindow[0]), " -", " ", formatUt(group.commonUtWindow[1])] }))] }), _jsxs(GroupActions, { children: [_jsx(TrayButton, { type: "button", onClick: () => downloadAll(group.recordings, objectUrls, labelFor), children: "Download all" }), _jsx(TrayButton, { type: "button", "$danger": true, onClick: onDiscardGroup, children: "Discard set" })] })] }), group.recordings.map((rec) => (_jsx(ClipRow, { rec: rec, label: labelFor(rec.flightId), url: objectUrls.get(rec.recordingId) }, rec.recordingId)))] }));
25
+ }
26
+ // ---------------------------------------------------------------------------
27
+ // Object-URL lifecycle
28
+ // ---------------------------------------------------------------------------
29
+ /**
30
+ * Creates one `URL.createObjectURL` per clip and revokes it once the clip
31
+ * disappears from `clips` (discarded) or on unmount. The map itself lives in
32
+ * a ref (not state) so the unmount cleanup always sees every URL ever
33
+ * created, even ones added after the effect that captured it first ran; a
34
+ * cheap reducer bump re-renders the caller whenever the map actually changes.
35
+ */
36
+ function useClipObjectUrls(clips) {
37
+ const mapRef = useRef(new Map());
38
+ const [, bump] = useReducer((c) => c + 1, 0);
39
+ useEffect(() => {
40
+ const map = mapRef.current;
41
+ const liveIds = new Set(clips.map((c) => c.recordingId));
42
+ let changed = false;
43
+ for (const [id, url] of map) {
44
+ if (!liveIds.has(id)) {
45
+ URL.revokeObjectURL(url);
46
+ map.delete(id);
47
+ changed = true;
48
+ }
49
+ }
50
+ for (const clip of clips) {
51
+ if (!map.has(clip.recordingId)) {
52
+ map.set(clip.recordingId, URL.createObjectURL(clip.blob));
53
+ changed = true;
54
+ }
55
+ }
56
+ if (changed)
57
+ bump();
58
+ }, [clips]);
59
+ useEffect(() => {
60
+ const map = mapRef.current;
61
+ return () => {
62
+ for (const url of map.values())
63
+ URL.revokeObjectURL(url);
64
+ map.clear();
65
+ };
66
+ }, []);
67
+ return mapRef.current;
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // Formatting + download helpers
71
+ // ---------------------------------------------------------------------------
72
+ /* KSP mission time (seconds); the tray shows the raw value rather than the
73
+ in-game calendar breakdown, which needs the epoch this component doesn't have. */
74
+ function formatUt(ut) {
75
+ return `T+${Math.round(ut)}s`;
76
+ }
77
+ function formatDuration(ms) {
78
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
79
+ const hours = Math.floor(totalSeconds / 3600);
80
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
81
+ const seconds = totalSeconds % 60;
82
+ const mm = hours > 0 ? minutes.toString().padStart(2, "0") : minutes.toString();
83
+ const ss = seconds.toString().padStart(2, "0");
84
+ return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
85
+ }
86
+ function formatBytes(bytes) {
87
+ if (bytes < 1024)
88
+ return `${bytes} B`;
89
+ const kb = bytes / 1024;
90
+ if (kb < 1024)
91
+ return `${kb.toFixed(1)} KB`;
92
+ return `${(kb / 1024).toFixed(1)} MB`;
93
+ }
94
+ function clipFileName(rec, label) {
95
+ const ext = /mp4/i.test(rec.mimeType) ? "mp4" : "webm";
96
+ const safeLabel = label.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "") || "clip";
97
+ return `${safeLabel}-${rec.recordingId}.${ext}`;
98
+ }
99
+ /* A set's "download all": one <a> per member, clicked in turn. Each still
100
+ downloads under its own clip filename (no zipping); good enough for an
101
+ operator who wants every feed of the moment without N separate clicks. */
102
+ function downloadAll(members, urls, labelFor) {
103
+ for (const member of members) {
104
+ const url = urls.get(member.recordingId);
105
+ if (!url)
106
+ continue;
107
+ const a = document.createElement("a");
108
+ a.href = url;
109
+ a.download = clipFileName(member, labelFor(member.flightId));
110
+ document.body.appendChild(a);
111
+ a.click();
112
+ a.remove();
113
+ }
114
+ }
115
+ // ---------------------------------------------------------------------------
116
+ // Styled components
117
+ // ---------------------------------------------------------------------------
118
+ const Tray = styled.div `
119
+ display: flex;
120
+ flex-direction: column;
121
+ gap: 8px;
122
+ min-width: 260px;
123
+ max-height: min(70vh, 520px);
124
+ overflow-y: auto;
125
+ overflow-x: hidden;
126
+ padding: 10px;
127
+ background: var(--kerbcast-tray-bg, rgba(0, 0, 0, 0.85));
128
+ border: 1px solid var(--kerbcast-tray-border, rgba(255, 255, 255, 0.3));
129
+ border-radius: 6px;
130
+ color: var(--kerbcast-tray-text, #fff);
131
+ font-size: 12px;
132
+ `;
133
+ const Empty = styled.div `
134
+ padding: 16px 8px;
135
+ text-align: center;
136
+ color: var(--kerbcast-tray-text-muted, rgba(255, 255, 255, 0.65));
137
+ font-style: italic;
138
+ `;
139
+ const Notice = styled.div `
140
+ padding: 2px 2px 4px;
141
+ color: var(--kerbcast-tray-text-muted, rgba(255, 255, 255, 0.55));
142
+ font-size: 10.5px;
143
+ font-style: italic;
144
+ letter-spacing: 0.01em;
145
+ `;
146
+ const ClipList = styled.div `
147
+ display: flex;
148
+ flex-direction: column;
149
+ gap: 8px;
150
+ `;
151
+ const Clip = styled.div `
152
+ display: flex;
153
+ flex-wrap: wrap;
154
+ align-items: flex-start;
155
+ gap: 8px;
156
+ padding: 6px 0;
157
+ border-top: 1px solid var(--kerbcast-tray-border-subtle, rgba(255, 255, 255, 0.12));
158
+
159
+ &:first-child {
160
+ border-top: none;
161
+ padding-top: 0;
162
+ }
163
+ `;
164
+ const ClipPreview = styled.video `
165
+ width: 120px;
166
+ aspect-ratio: 16 / 9;
167
+ background: #000;
168
+ border-radius: 4px;
169
+ flex-shrink: 0;
170
+ object-fit: cover;
171
+ `;
172
+ const ClipPreviewPlaceholder = styled.div `
173
+ width: 120px;
174
+ aspect-ratio: 16 / 9;
175
+ background: rgba(255, 255, 255, 0.05);
176
+ border-radius: 4px;
177
+ flex-shrink: 0;
178
+ `;
179
+ const ClipMeta = styled.div `
180
+ display: flex;
181
+ flex-direction: column;
182
+ gap: 2px;
183
+ flex: 1 1 140px;
184
+ min-width: 0;
185
+ `;
186
+ const ClipName = styled.div `
187
+ font-weight: 600;
188
+ letter-spacing: 0.03em;
189
+ overflow: hidden;
190
+ text-overflow: ellipsis;
191
+ white-space: nowrap;
192
+ `;
193
+ const ClipDetail = styled.div `
194
+ color: var(--kerbcast-tray-text-muted, rgba(255, 255, 255, 0.65));
195
+ font-size: 11px;
196
+ `;
197
+ const ClipActions = styled.div `
198
+ display: flex;
199
+ flex-direction: column;
200
+ align-items: flex-start;
201
+ gap: 4px;
202
+ flex-shrink: 0;
203
+ `;
204
+ const trayButtonBase = `
205
+ padding: 3px 8px;
206
+ border-radius: 3px;
207
+ font-size: 11px;
208
+ letter-spacing: 0.03em;
209
+ text-decoration: none;
210
+ cursor: pointer;
211
+ white-space: nowrap;
212
+ `;
213
+ const TrayButton = styled.button `
214
+ ${trayButtonBase}
215
+ background: transparent;
216
+ border: 1px solid
217
+ ${(p) => p.$danger
218
+ ? "var(--kerbcast-tray-danger, #ff6b5e)"
219
+ : "var(--kerbcast-accent, #00ff88)"};
220
+ color: ${(p) => (p.$danger ? "var(--kerbcast-tray-danger, #ff6b5e)" : "var(--kerbcast-accent, #00ff88)")};
221
+
222
+ @media (hover: hover) {
223
+ &:hover {
224
+ background: ${(p) => p.$danger ? "rgba(255, 107, 94, 0.15)" : "var(--kerbcast-accent-wash, rgba(0, 255, 136, 0.15))"};
225
+ }
226
+ }
227
+ `;
228
+ const TrayLink = styled.a `
229
+ ${trayButtonBase}
230
+ display: inline-block;
231
+ background: transparent;
232
+ border: 1px solid var(--kerbcast-accent, #00ff88);
233
+ color: var(--kerbcast-accent, #00ff88);
234
+
235
+ @media (hover: hover) {
236
+ &:hover {
237
+ background: var(--kerbcast-accent-wash, rgba(0, 255, 136, 0.15));
238
+ }
239
+ }
240
+ `;
241
+ const GroupCard = styled.div `
242
+ display: flex;
243
+ flex-direction: column;
244
+ gap: 4px;
245
+ padding: 8px;
246
+ background: var(--kerbcast-tray-surface-raised, rgba(255, 255, 255, 0.04));
247
+ border: 1px solid var(--kerbcast-tray-border-subtle, rgba(255, 255, 255, 0.12));
248
+ border-left: 2px solid var(--kerbcast-accent, #00ff88);
249
+ border-radius: 4px;
250
+
251
+ ${Clip} {
252
+ border-top-color: var(--kerbcast-tray-border-subtle, rgba(255, 255, 255, 0.12));
253
+ }
254
+ `;
255
+ const GroupHeader = styled.div `
256
+ display: flex;
257
+ flex-wrap: wrap;
258
+ align-items: flex-start;
259
+ justify-content: space-between;
260
+ gap: 8px;
261
+ `;
262
+ const GroupHeaderText = styled.div `
263
+ display: flex;
264
+ flex-direction: column;
265
+ gap: 2px;
266
+ `;
267
+ const GroupTitle = styled.div `
268
+ font-weight: 600;
269
+ letter-spacing: 0.03em;
270
+ `;
271
+ const GroupMeta = styled.div `
272
+ color: var(--kerbcast-tray-text-muted, rgba(255, 255, 255, 0.65));
273
+ font-size: 11px;
274
+ `;
275
+ const GroupActions = styled.div `
276
+ display: flex;
277
+ gap: 6px;
278
+ flex-shrink: 0;
279
+ `;
package/dist/context.d.ts CHANGED
@@ -11,17 +11,38 @@ export interface KerbcastSubscriptions {
11
11
  }
12
12
  /** Build the default refcounted subscriptions implementation for a client. */
13
13
  export declare function createClientSubscriptions(client: KerbcastClient): KerbcastSubscriptions;
14
+ /**
15
+ * Collects each mounted feed's self-measured display size and drives the
16
+ * client's per-consumer `reportDisplaySize`. Multiple feeds can show the SAME
17
+ * camera at once (e.g. a tiny avatar and a spotlight of the same kerbal); the
18
+ * sidecar keys the auto-resolution max per CONSUMER, so those would clobber
19
+ * each other last-writer-wins. This collapses them at the client edge: it
20
+ * keeps the MAX display size across every mounted reporter for a flightId and
21
+ * sends one report per max-change. Co-located with the subscription refcount
22
+ * (same per-flightId lifetime) so one object owns everything this client is
23
+ * doing with camera N.
24
+ */
25
+ export interface KerbcastDisplaySizes {
26
+ /** Register/update a reporter's measured size for a camera. */
27
+ report(flightId: number, instanceId: string, width: number, height: number): void;
28
+ /** Deregister a reporter (on unmount / flightId change). */
29
+ clear(flightId: number, instanceId: string): void;
30
+ }
31
+ /** Build the default per-flightId MAX display-size registry for a client. */
32
+ export declare function createClientDisplaySizes(client: KerbcastClient): KerbcastDisplaySizes;
14
33
  export interface KerbcastProviderProps {
15
34
  client: KerbcastClient;
16
35
  /** Override the subscription manager. Defaults to a refcounted per-client impl. */
17
36
  subscriptions?: KerbcastSubscriptions;
37
+ /** Override the display-size registry. Defaults to a per-flightId MAX impl. */
38
+ displaySizes?: KerbcastDisplaySizes;
18
39
  children: ReactNode;
19
40
  }
20
41
  /**
21
42
  * Provide a `KerbcastClient` (and optional subscriptions override) to the
22
43
  * component subtree. Hooks and `CameraFeed` read both from this context.
23
44
  */
24
- export declare function KerbcastProvider({ client, subscriptions: subscriptionsProp, children, }: KerbcastProviderProps): React.JSX.Element;
45
+ export declare function KerbcastProvider({ client, subscriptions: subscriptionsProp, displaySizes: displaySizesProp, children, }: KerbcastProviderProps): React.JSX.Element;
25
46
  /**
26
47
  * Return the `KerbcastClient` from the nearest `KerbcastProvider`.
27
48
  * Throws if called outside a provider.
@@ -32,3 +53,8 @@ export declare function useKerbcastClient(): KerbcastClient;
32
53
  * Throws if called outside a provider.
33
54
  */
34
55
  export declare function useKerbcastSubscriptions(): KerbcastSubscriptions;
56
+ /**
57
+ * Return the `KerbcastDisplaySizes` registry from the nearest
58
+ * `KerbcastProvider`. Throws if called outside a provider.
59
+ */
60
+ export declare function useKerbcastDisplaySizes(): KerbcastDisplaySizes;
package/dist/context.js CHANGED
@@ -23,24 +23,79 @@ export function createClientSubscriptions(client) {
23
23
  },
24
24
  };
25
25
  }
26
+ /** Build the default per-flightId MAX display-size registry for a client. */
27
+ export function createClientDisplaySizes(client) {
28
+ // flightId -> (instanceId -> measured size)
29
+ const perCamera = new Map();
30
+ // Last size reported to the sidecar per flightId, so we send only on change.
31
+ const lastSent = new Map();
32
+ function recompute(flightId) {
33
+ const instances = perCamera.get(flightId);
34
+ if (!instances || instances.size === 0) {
35
+ // No reporters left. Drop bookkeeping and send nothing: the subscription
36
+ // release (unsubscribe -> sidecar remove_track -> forget_display_size)
37
+ // clears the sidecar side. Sending a spurious size here would fight it.
38
+ perCamera.delete(flightId);
39
+ lastSent.delete(flightId);
40
+ return;
41
+ }
42
+ let maxW = 0;
43
+ let maxH = 0;
44
+ for (const { w, h } of instances.values()) {
45
+ if (w > maxW)
46
+ maxW = w;
47
+ if (h > maxH)
48
+ maxH = h;
49
+ }
50
+ const prev = lastSent.get(flightId);
51
+ if (prev && prev.w === maxW && prev.h === maxH)
52
+ return;
53
+ lastSent.set(flightId, { w: maxW, h: maxH });
54
+ client.reportDisplaySize(flightId, maxW, maxH).catch(() => { });
55
+ }
56
+ return {
57
+ report(flightId, instanceId, width, height) {
58
+ let instances = perCamera.get(flightId);
59
+ if (!instances) {
60
+ instances = new Map();
61
+ perCamera.set(flightId, instances);
62
+ }
63
+ instances.set(instanceId, { w: width, h: height });
64
+ recompute(flightId);
65
+ },
66
+ clear(flightId, instanceId) {
67
+ const instances = perCamera.get(flightId);
68
+ if (!instances)
69
+ return;
70
+ instances.delete(instanceId);
71
+ recompute(flightId);
72
+ },
73
+ };
74
+ }
26
75
  const KerbcastContext = createContext(null);
27
76
  /**
28
77
  * Provide a `KerbcastClient` (and optional subscriptions override) to the
29
78
  * component subtree. Hooks and `CameraFeed` read both from this context.
30
79
  */
31
- export function KerbcastProvider({ client, subscriptions: subscriptionsProp, children, }) {
80
+ export function KerbcastProvider({ client, subscriptions: subscriptionsProp, displaySizes: displaySizesProp, children, }) {
32
81
  // Create default subscriptions once per client. A ref so the object is
33
82
  // stable across renders even if the parent re-renders.
34
83
  const defaultSubsRef = useRef(null);
35
84
  if (defaultSubsRef.current === null) {
36
85
  defaultSubsRef.current = createClientSubscriptions(client);
37
86
  }
87
+ // Same for the display-size registry: one per client, stable across renders.
88
+ const defaultSizesRef = useRef(null);
89
+ if (defaultSizesRef.current === null) {
90
+ defaultSizesRef.current = createClientDisplaySizes(client);
91
+ }
38
92
  const value = useMemo(() => ({
39
93
  client,
40
94
  subscriptions: subscriptionsProp ?? defaultSubsRef.current,
95
+ displaySizes: displaySizesProp ?? defaultSizesRef.current,
41
96
  }),
42
97
  // eslint-disable-next-line react-hooks/exhaustive-deps
43
- [client, subscriptionsProp]);
98
+ [client, subscriptionsProp, displaySizesProp]);
44
99
  return (_jsx(KerbcastContext.Provider, { value: value, children: children }));
45
100
  }
46
101
  // ---------------------------------------------------------------------------
@@ -68,3 +123,14 @@ export function useKerbcastSubscriptions() {
68
123
  }
69
124
  return ctx.subscriptions;
70
125
  }
126
+ /**
127
+ * Return the `KerbcastDisplaySizes` registry from the nearest
128
+ * `KerbcastProvider`. Throws if called outside a provider.
129
+ */
130
+ export function useKerbcastDisplaySizes() {
131
+ const ctx = useContext(KerbcastContext);
132
+ if (!ctx) {
133
+ throw new Error("useKerbcastDisplaySizes must be used inside a KerbcastProvider");
134
+ }
135
+ return ctx.displaySizes;
136
+ }
@@ -0,0 +1,35 @@
1
+ import type { ActiveRecordingInfo, GroupedRecordingHandle, RecordingHandle, StartRecordingOptions, StopGroupedRecordingOptions } from "@ksp-gonogo/kerbcast";
2
+ export type { ActiveRecordingInfo };
3
+ /**
4
+ * Reactive recordings store over the SDK's `client.recording` controller.
5
+ *
6
+ * Holds the in-session, in-memory clips: standalone singles in `recordings`,
7
+ * synchronised sets in `groups`, and the still-running ones in `active`. This
8
+ * is the headless data layer the recordings tray + REC controls sit on; it
9
+ * owns no chrome. All state is in-memory, so a page reload discards it (the UI
10
+ * guards navigation while unsaved clips exist).
11
+ */
12
+ export interface RecordingsStore {
13
+ /** Finished standalone (non-grouped) clips, newest last. */
14
+ recordings: RecordingHandle[];
15
+ /** Finished grouped sets, newest last. */
16
+ groups: GroupedRecordingHandle[];
17
+ /** Recordings currently in progress (single + grouped members). */
18
+ active: ActiveRecordingInfo[];
19
+ /** Whether a feed currently has a recording in progress. */
20
+ isRecording(flightId: number): boolean;
21
+ start(flightId: number, opts?: StartRecordingOptions): string;
22
+ stop(recordingId: string): Promise<RecordingHandle>;
23
+ startGroup(flightIds: number[], opts?: StartRecordingOptions): string;
24
+ stopGroup(groupId: string, opts?: StopGroupedRecordingOptions): Promise<GroupedRecordingHandle>;
25
+ discard(recordingId: string): void;
26
+ discardGroup(groupId: string): void;
27
+ }
28
+ /**
29
+ * Subscribe a component to the recording store for the context client. The
30
+ * controller itself is the single source of truth for every call site;
31
+ * `useSyncExternalStore` re-renders this component whenever the controller's
32
+ * snapshot changes, no matter which call site (or which component) drove the
33
+ * change.
34
+ */
35
+ export declare function useRecordings(): RecordingsStore;
@@ -0,0 +1,33 @@
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+ import { useKerbcastClient } from "../context";
3
+ /**
4
+ * Subscribe a component to the recording store for the context client. The
5
+ * controller itself is the single source of truth for every call site;
6
+ * `useSyncExternalStore` re-renders this component whenever the controller's
7
+ * snapshot changes, no matter which call site (or which component) drove the
8
+ * change.
9
+ */
10
+ export function useRecordings() {
11
+ const client = useKerbcastClient();
12
+ const controller = client.recording;
13
+ const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
14
+ const start = useCallback((flightId, opts) => controller.startRecording(flightId, opts), [controller]);
15
+ const stop = useCallback((recordingId) => controller.stopRecording(recordingId), [controller]);
16
+ const startGroup = useCallback((flightIds, opts) => controller.startGroupedRecording(flightIds, opts).groupId, [controller]);
17
+ const stopGroup = useCallback((groupId, opts) => controller.stopGroupedRecording(groupId, opts), [controller]);
18
+ const discard = useCallback((recordingId) => controller.discardRecording(recordingId), [controller]);
19
+ const discardGroup = useCallback((groupId) => controller.discardGroupedRecording(groupId), [controller]);
20
+ const isRecording = useCallback((flightId) => controller.isRecording(flightId), [controller]);
21
+ return {
22
+ recordings: snapshot.recordings,
23
+ groups: snapshot.groups,
24
+ active: snapshot.active,
25
+ isRecording,
26
+ start,
27
+ stop,
28
+ startGroup,
29
+ stopGroup,
30
+ discard,
31
+ discardGroup,
32
+ };
33
+ }
@@ -0,0 +1,23 @@
1
+ export interface ReportDisplaySizeOptions {
2
+ /**
3
+ * Report while mounted. Default true. Set false for a fixed-resolution feed
4
+ * that should not drive auto-resolution. This is a RESOLUTION concern only,
5
+ * independent of whether the feed shows its action UI.
6
+ */
7
+ enabled?: boolean;
8
+ /**
9
+ * Report a square (side x side, the min edge) rather than the raw w x h.
10
+ * Face cams are square; part cams are not.
11
+ */
12
+ square?: boolean;
13
+ }
14
+ /**
15
+ * Self-measure a feed element and drive the client's per-consumer
16
+ * `reportDisplaySize` (via the context display-size registry, which collapses
17
+ * multiple feeds of the same camera to one MAX report). Attach `ref` to the
18
+ * element whose rendered pixel box is the display size. Reporting follows the
19
+ * mount lifecycle: on unmount / `flightId` change / `enabled` flip it
20
+ * deregisters, and the sidecar's clear-on-departure (unsubscribe) does the
21
+ * server-side clear.
22
+ */
23
+ export declare function useReportDisplaySize(flightId: number | null, ref: React.RefObject<Element | null>, { enabled, square }?: ReportDisplaySizeOptions): void;
@@ -0,0 +1,71 @@
1
+ import { useEffect, useId } from "react";
2
+ import { useKerbcastDisplaySizes } from "../context";
3
+ /*
4
+ * Debounce + bucket for self-measured display-size reporting. The first
5
+ * measurement after mount reports promptly (so a feed never sits at a stale
6
+ * resolution until it is resized); later churn is debounced. Sizes are ceiled
7
+ * to a BUCKET_PX multiple: that meets-the-minimum-need (never under-serve),
8
+ * yields an even value (H.264-friendly), and gives a material-change threshold
9
+ * so a feed re-reports only when a resize crosses a bucket boundary.
10
+ */
11
+ const DEBOUNCE_MS = 200;
12
+ const BUCKET_PX = 16;
13
+ function bucket(px) {
14
+ const b = Math.ceil(px / BUCKET_PX) * BUCKET_PX;
15
+ return b < BUCKET_PX ? BUCKET_PX : b;
16
+ }
17
+ /**
18
+ * Self-measure a feed element and drive the client's per-consumer
19
+ * `reportDisplaySize` (via the context display-size registry, which collapses
20
+ * multiple feeds of the same camera to one MAX report). Attach `ref` to the
21
+ * element whose rendered pixel box is the display size. Reporting follows the
22
+ * mount lifecycle: on unmount / `flightId` change / `enabled` flip it
23
+ * deregisters, and the sidecar's clear-on-departure (unsubscribe) does the
24
+ * server-side clear.
25
+ */
26
+ export function useReportDisplaySize(flightId, ref, { enabled = true, square = false } = {}) {
27
+ const displaySizes = useKerbcastDisplaySizes();
28
+ const instanceId = useId();
29
+ useEffect(() => {
30
+ if (flightId === null || !enabled)
31
+ return;
32
+ const el = ref.current;
33
+ if (!el)
34
+ return;
35
+ let timer = null;
36
+ let lastW = -1;
37
+ let lastH = -1;
38
+ let firstReported = false;
39
+ const report = (width, height) => {
40
+ const side = square ? Math.min(width, height) : 0;
41
+ const bw = bucket(square ? side : width);
42
+ const bh = bucket(square ? side : height);
43
+ if (bw === lastW && bh === lastH)
44
+ return;
45
+ lastW = bw;
46
+ lastH = bh;
47
+ displaySizes.report(flightId, instanceId, bw, bh);
48
+ };
49
+ const observer = new ResizeObserver((entries) => {
50
+ const entry = entries[0];
51
+ if (!entry)
52
+ return;
53
+ const { width, height } = entry.contentRect;
54
+ if (!firstReported) {
55
+ firstReported = true;
56
+ report(width, height);
57
+ return;
58
+ }
59
+ if (timer !== null)
60
+ clearTimeout(timer);
61
+ timer = setTimeout(() => report(width, height), DEBOUNCE_MS);
62
+ });
63
+ observer.observe(el);
64
+ return () => {
65
+ observer.disconnect();
66
+ if (timer !== null)
67
+ clearTimeout(timer);
68
+ displaySizes.clear(flightId, instanceId);
69
+ };
70
+ }, [displaySizes, flightId, ref, enabled, square, instanceId]);
71
+ }
package/dist/index.d.ts CHANGED
@@ -5,12 +5,16 @@
5
5
  * is a peer to @ksp-gonogo/kerbcast (the core SDK) and requires React 18+
6
6
  * and styled-components 6+ as peer dependencies.
7
7
  */
8
- export { KerbcastProvider, createClientSubscriptions, useKerbcastClient, useKerbcastSubscriptions, } from "./context";
9
- export type { KerbcastProviderProps, KerbcastSubscriptions } from "./context";
8
+ export { KerbcastProvider, createClientSubscriptions, createClientDisplaySizes, useKerbcastClient, useKerbcastSubscriptions, useKerbcastDisplaySizes, } from "./context";
9
+ export type { KerbcastProviderProps, KerbcastSubscriptions, KerbcastDisplaySizes, } from "./context";
10
10
  export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
11
11
  export { useKerbcastStream } from "./hooks/useKerbcastStream";
12
12
  export { useKerbcastClock } from "./hooks/useKerbcastClock";
13
13
  export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
14
+ export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
15
+ export { useRecordings } from "./hooks/useRecordings";
16
+ export type { RecordingsStore, ActiveRecordingInfo } from "./hooks/useRecordings";
17
+ export { nowMs, formatElapsed } from "./timing";
14
18
  export { StandbyIcon } from "./StandbyIcon";
15
19
  export { buildCameraLabeler } from "./cameraLabels";
16
20
  export type { LabelableCamera } from "./cameraLabels";
@@ -18,3 +22,9 @@ export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
18
22
  export type { CameraLifecycle } from "./lifecycle";
19
23
  export { CameraFeed } from "./CameraFeed";
20
24
  export type { CameraFeedHandle, CameraFeedProps, CameraStreamHook, FeedAction, } from "./CameraFeed";
25
+ export { FeedActionBar } from "./FeedActionBar";
26
+ export type { FeedActionBarEntry } from "./FeedActionBar";
27
+ export { KerbalFaceFeed } from "./KerbalFaceFeed";
28
+ export type { KerbalFaceFeedProps, KerbalFeedState } from "./KerbalFaceFeed";
29
+ export { RecordingsTray } from "./RecordingsTray";
30
+ export type { RecordingsTrayProps } from "./RecordingsTray";
package/dist/index.js CHANGED
@@ -6,12 +6,16 @@
6
6
  * and styled-components 6+ as peer dependencies.
7
7
  */
8
8
  // Context and provider
9
- export { KerbcastProvider, createClientSubscriptions, useKerbcastClient, useKerbcastSubscriptions, } from "./context";
9
+ export { KerbcastProvider, createClientSubscriptions, createClientDisplaySizes, useKerbcastClient, useKerbcastSubscriptions, useKerbcastDisplaySizes, } from "./context";
10
10
  // Hooks
11
11
  export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
12
12
  export { useKerbcastStream } from "./hooks/useKerbcastStream";
13
13
  export { useKerbcastClock } from "./hooks/useKerbcastClock";
14
14
  export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
15
+ export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
16
+ export { useRecordings } from "./hooks/useRecordings";
17
+ // Shared elapsed-timer helpers (REC badges, RecGroupBar's toolbar)
18
+ export { nowMs, formatElapsed } from "./timing";
15
19
  // Shared standby glyph (used by CameraFeed + downstream dashboards)
16
20
  export { StandbyIcon } from "./StandbyIcon";
17
21
  // Camera label utilities
@@ -20,3 +24,11 @@ export { buildCameraLabeler } from "./cameraLabels";
20
24
  export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
21
25
  // CameraFeed component
22
26
  export { CameraFeed } from "./CameraFeed";
27
+ // FeedActionBar: the shared action row (tracking, fullscreen, PiP, quality,
28
+ // close, REC) both CameraFeed and KerbalFaceFeed render, with the issue-#6
29
+ // overflow (⋮) engine.
30
+ export { FeedActionBar } from "./FeedActionBar";
31
+ // KerbalFaceFeed — shared single-kerbal-face primitive
32
+ export { KerbalFaceFeed } from "./KerbalFaceFeed";
33
+ // RecordingsTray: a presentational drawer over the recordings store
34
+ export { RecordingsTray } from "./RecordingsTray";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Shared geometry + behaviour for a dropdown portaled to document.body.
3
+ * Used by CameraFeed's camera / quality / tracking menus and by
4
+ * FeedActionBar's overflow menu, so the anchoring, dismissal, and Escape
5
+ * handling live in exactly one place.
6
+ */
7
+ export interface MenuAnchor {
8
+ maxWidth: number;
9
+ maxHeight: number;
10
+ align: "start" | "end";
11
+ }
12
+ export declare function computeMenuPosition(trigger: HTMLElement, anchor: MenuAnchor): React.CSSProperties;
13
+ export declare function usePortalMenu({ maxWidth, maxHeight, align }: MenuAnchor): {
14
+ open: boolean;
15
+ toggle: () => void;
16
+ close: () => void;
17
+ position: import("react").CSSProperties | null;
18
+ menuRef: import("react").RefObject<HTMLDivElement>;
19
+ triggerRef: import("react").RefObject<HTMLButtonElement>;
20
+ };