@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.
- package/CHANGELOG.md +54 -0
- package/README.md +19 -0
- package/dist/CameraFeed.d.ts +63 -19
- package/dist/CameraFeed.js +329 -202
- package/dist/FeedActionBar.d.ts +133 -0
- package/dist/FeedActionBar.js +225 -0
- package/dist/KerbalFaceFeed.d.ts +82 -0
- package/dist/KerbalFaceFeed.js +111 -0
- package/dist/RecordingsTray.d.ts +37 -0
- package/dist/RecordingsTray.js +279 -0
- package/dist/context.d.ts +27 -1
- package/dist/context.js +68 -2
- package/dist/hooks/useRecordings.d.ts +35 -0
- package/dist/hooks/useRecordings.js +33 -0
- package/dist/hooks/useReportDisplaySize.d.ts +23 -0
- package/dist/hooks/useReportDisplaySize.js +71 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +13 -1
- package/dist/menuPositioning.d.ts +20 -0
- package/dist/menuPositioning.js +98 -0
- package/dist/timing.d.ts +4 -0
- package/dist/timing.js +19 -0
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +719 -10
- package/src/CameraFeed.tsx +586 -334
- package/src/FeedActionBar.test.ts +196 -0
- package/src/FeedActionBar.test.tsx +215 -0
- package/src/FeedActionBar.tsx +391 -0
- package/src/KerbalFaceFeed.test.tsx +203 -0
- package/src/KerbalFaceFeed.tsx +214 -0
- package/src/RecordingsTray.test.tsx +288 -0
- package/src/RecordingsTray.tsx +475 -0
- package/src/context.test.tsx +94 -0
- package/src/context.tsx +98 -1
- package/src/hooks/useRecordings.sharedState.test.tsx +117 -0
- package/src/hooks/useRecordings.test.tsx +217 -0
- package/src/hooks/useRecordings.ts +110 -0
- package/src/hooks/useReportDisplaySize.test.tsx +166 -0
- package/src/hooks/useReportDisplaySize.ts +91 -0
- package/src/index.ts +27 -1
- package/src/menuPositioning.ts +126 -0
- package/src/timing.test.ts +34 -0
- 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
|
+
};
|