@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,475 @@
|
|
|
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
|
+
|
|
25
|
+
import type { GroupedRecordingHandle, RecordingHandle } from "@ksp-gonogo/kerbcast";
|
|
26
|
+
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
|
27
|
+
import styled from "styled-components";
|
|
28
|
+
import { useRecordings } from "./hooks/useRecordings";
|
|
29
|
+
|
|
30
|
+
export interface RecordingsTrayProps {
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a flightId to a display label for a clip's camera name. Omit for
|
|
33
|
+
* a plain "Camera <flightId>" fallback.
|
|
34
|
+
*/
|
|
35
|
+
cameraLabel?: (flightId: number) => string;
|
|
36
|
+
/** Forwarded to the root panel; lets a host lay out/size the tray. */
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The shared drawer. Requires a `KerbcastProvider` ancestor (it calls
|
|
42
|
+
* `useRecordings()` directly, same requirement as `CameraFeed`).
|
|
43
|
+
*/
|
|
44
|
+
export function RecordingsTray({ cameraLabel, className }: RecordingsTrayProps) {
|
|
45
|
+
const store = useRecordings();
|
|
46
|
+
|
|
47
|
+
const labelFor = useCallback(
|
|
48
|
+
(flightId: number): string => (cameraLabel ? cameraLabel(flightId) : `Camera ${flightId}`),
|
|
49
|
+
[cameraLabel],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const allClips = useMemo(
|
|
53
|
+
() => [...store.recordings, ...store.groups.flatMap((g) => g.recordings)],
|
|
54
|
+
[store.recordings, store.groups],
|
|
55
|
+
);
|
|
56
|
+
const objectUrls = useClipObjectUrls(allClips);
|
|
57
|
+
|
|
58
|
+
const hasAny = store.recordings.length > 0 || store.groups.length > 0;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Tray className={className} aria-label="Recordings">
|
|
62
|
+
{!hasAny ? (
|
|
63
|
+
<Empty>No recordings yet. Press REC on a feed to start one.</Empty>
|
|
64
|
+
) : (
|
|
65
|
+
<>
|
|
66
|
+
<Notice>
|
|
67
|
+
Unsaved clips are lost on reload. Download what you want to keep.
|
|
68
|
+
</Notice>
|
|
69
|
+
<ClipList>
|
|
70
|
+
{store.recordings.map((rec) => (
|
|
71
|
+
<ClipRow
|
|
72
|
+
key={rec.recordingId}
|
|
73
|
+
rec={rec}
|
|
74
|
+
label={labelFor(rec.flightId)}
|
|
75
|
+
url={objectUrls.get(rec.recordingId)}
|
|
76
|
+
onDiscard={() => store.discard(rec.recordingId)}
|
|
77
|
+
/>
|
|
78
|
+
))}
|
|
79
|
+
{store.groups.map((group) => (
|
|
80
|
+
<GroupItem
|
|
81
|
+
key={group.groupId}
|
|
82
|
+
group={group}
|
|
83
|
+
labelFor={labelFor}
|
|
84
|
+
objectUrls={objectUrls}
|
|
85
|
+
onDiscardGroup={() => store.discardGroup(group.groupId)}
|
|
86
|
+
/>
|
|
87
|
+
))}
|
|
88
|
+
</ClipList>
|
|
89
|
+
</>
|
|
90
|
+
)}
|
|
91
|
+
</Tray>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Sub-rows
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
interface ClipRowProps {
|
|
100
|
+
rec: RecordingHandle;
|
|
101
|
+
label: string;
|
|
102
|
+
url: string | undefined;
|
|
103
|
+
/** Omitted for a clip inside a grouped set: only the whole set discards. */
|
|
104
|
+
onDiscard?: () => void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function ClipRow({ rec, label, url, onDiscard }: ClipRowProps) {
|
|
108
|
+
const utRange =
|
|
109
|
+
rec.utStart != null && rec.utEnd != null
|
|
110
|
+
? `${formatUt(rec.utStart)} - ${formatUt(rec.utEnd)}`
|
|
111
|
+
: "no UT (out of flight)";
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<Clip>
|
|
115
|
+
{url ? (
|
|
116
|
+
<ClipPreview controls preload="metadata" src={url} aria-label={`Preview: ${label}`} />
|
|
117
|
+
) : (
|
|
118
|
+
<ClipPreviewPlaceholder aria-hidden="true" />
|
|
119
|
+
)}
|
|
120
|
+
<ClipMeta>
|
|
121
|
+
<ClipName>{label}</ClipName>
|
|
122
|
+
<ClipDetail>{utRange}</ClipDetail>
|
|
123
|
+
<ClipDetail>
|
|
124
|
+
{formatDuration(rec.durationMs)} · {formatBytes(rec.byteSize)}
|
|
125
|
+
</ClipDetail>
|
|
126
|
+
</ClipMeta>
|
|
127
|
+
<ClipActions>
|
|
128
|
+
{url && (
|
|
129
|
+
<TrayLink href={url} download={clipFileName(rec, label)}>
|
|
130
|
+
Download
|
|
131
|
+
</TrayLink>
|
|
132
|
+
)}
|
|
133
|
+
{onDiscard && (
|
|
134
|
+
<TrayButton type="button" $danger onClick={onDiscard}>
|
|
135
|
+
Discard
|
|
136
|
+
</TrayButton>
|
|
137
|
+
)}
|
|
138
|
+
</ClipActions>
|
|
139
|
+
</Clip>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface GroupItemProps {
|
|
144
|
+
group: GroupedRecordingHandle;
|
|
145
|
+
labelFor: (flightId: number) => string;
|
|
146
|
+
objectUrls: Map<string, string>;
|
|
147
|
+
onDiscardGroup: () => void;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function GroupItem({ group, labelFor, objectUrls, onDiscardGroup }: GroupItemProps) {
|
|
151
|
+
return (
|
|
152
|
+
<GroupCard aria-label={`Grouped recording, ${group.recordings.length} feeds`}>
|
|
153
|
+
<GroupHeader>
|
|
154
|
+
<GroupHeaderText>
|
|
155
|
+
<GroupTitle>
|
|
156
|
+
Grouped recording · {group.recordings.length} feeds
|
|
157
|
+
</GroupTitle>
|
|
158
|
+
{group.commonUtWindow && (
|
|
159
|
+
<GroupMeta>
|
|
160
|
+
common UT {formatUt(group.commonUtWindow[0])} -{" "}
|
|
161
|
+
{formatUt(group.commonUtWindow[1])}
|
|
162
|
+
</GroupMeta>
|
|
163
|
+
)}
|
|
164
|
+
</GroupHeaderText>
|
|
165
|
+
<GroupActions>
|
|
166
|
+
<TrayButton
|
|
167
|
+
type="button"
|
|
168
|
+
onClick={() => downloadAll(group.recordings, objectUrls, labelFor)}
|
|
169
|
+
>
|
|
170
|
+
Download all
|
|
171
|
+
</TrayButton>
|
|
172
|
+
<TrayButton type="button" $danger onClick={onDiscardGroup}>
|
|
173
|
+
Discard set
|
|
174
|
+
</TrayButton>
|
|
175
|
+
</GroupActions>
|
|
176
|
+
</GroupHeader>
|
|
177
|
+
{group.recordings.map((rec) => (
|
|
178
|
+
<ClipRow
|
|
179
|
+
key={rec.recordingId}
|
|
180
|
+
rec={rec}
|
|
181
|
+
label={labelFor(rec.flightId)}
|
|
182
|
+
url={objectUrls.get(rec.recordingId)}
|
|
183
|
+
/>
|
|
184
|
+
))}
|
|
185
|
+
</GroupCard>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Object-URL lifecycle
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Creates one `URL.createObjectURL` per clip and revokes it once the clip
|
|
195
|
+
* disappears from `clips` (discarded) or on unmount. The map itself lives in
|
|
196
|
+
* a ref (not state) so the unmount cleanup always sees every URL ever
|
|
197
|
+
* created, even ones added after the effect that captured it first ran; a
|
|
198
|
+
* cheap reducer bump re-renders the caller whenever the map actually changes.
|
|
199
|
+
*/
|
|
200
|
+
function useClipObjectUrls(clips: RecordingHandle[]): Map<string, string> {
|
|
201
|
+
const mapRef = useRef(new Map<string, string>());
|
|
202
|
+
const [, bump] = useReducer((c: number) => c + 1, 0);
|
|
203
|
+
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
const map = mapRef.current;
|
|
206
|
+
const liveIds = new Set(clips.map((c) => c.recordingId));
|
|
207
|
+
let changed = false;
|
|
208
|
+
|
|
209
|
+
for (const [id, url] of map) {
|
|
210
|
+
if (!liveIds.has(id)) {
|
|
211
|
+
URL.revokeObjectURL(url);
|
|
212
|
+
map.delete(id);
|
|
213
|
+
changed = true;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const clip of clips) {
|
|
217
|
+
if (!map.has(clip.recordingId)) {
|
|
218
|
+
map.set(clip.recordingId, URL.createObjectURL(clip.blob));
|
|
219
|
+
changed = true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (changed) bump();
|
|
223
|
+
}, [clips]);
|
|
224
|
+
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
const map = mapRef.current;
|
|
227
|
+
return () => {
|
|
228
|
+
for (const url of map.values()) URL.revokeObjectURL(url);
|
|
229
|
+
map.clear();
|
|
230
|
+
};
|
|
231
|
+
}, []);
|
|
232
|
+
|
|
233
|
+
return mapRef.current;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Formatting + download helpers
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
/* KSP mission time (seconds); the tray shows the raw value rather than the
|
|
241
|
+
in-game calendar breakdown, which needs the epoch this component doesn't have. */
|
|
242
|
+
function formatUt(ut: number): string {
|
|
243
|
+
return `T+${Math.round(ut)}s`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function formatDuration(ms: number): string {
|
|
247
|
+
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
|
248
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
249
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
250
|
+
const seconds = totalSeconds % 60;
|
|
251
|
+
const mm = hours > 0 ? minutes.toString().padStart(2, "0") : minutes.toString();
|
|
252
|
+
const ss = seconds.toString().padStart(2, "0");
|
|
253
|
+
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function formatBytes(bytes: number): string {
|
|
257
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
258
|
+
const kb = bytes / 1024;
|
|
259
|
+
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
|
260
|
+
return `${(kb / 1024).toFixed(1)} MB`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function clipFileName(rec: RecordingHandle, label: string): string {
|
|
264
|
+
const ext = /mp4/i.test(rec.mimeType) ? "mp4" : "webm";
|
|
265
|
+
const safeLabel = label.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "") || "clip";
|
|
266
|
+
return `${safeLabel}-${rec.recordingId}.${ext}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* A set's "download all": one <a> per member, clicked in turn. Each still
|
|
270
|
+
downloads under its own clip filename (no zipping); good enough for an
|
|
271
|
+
operator who wants every feed of the moment without N separate clicks. */
|
|
272
|
+
function downloadAll(
|
|
273
|
+
members: RecordingHandle[],
|
|
274
|
+
urls: Map<string, string>,
|
|
275
|
+
labelFor: (flightId: number) => string,
|
|
276
|
+
): void {
|
|
277
|
+
for (const member of members) {
|
|
278
|
+
const url = urls.get(member.recordingId);
|
|
279
|
+
if (!url) continue;
|
|
280
|
+
const a = document.createElement("a");
|
|
281
|
+
a.href = url;
|
|
282
|
+
a.download = clipFileName(member, labelFor(member.flightId));
|
|
283
|
+
document.body.appendChild(a);
|
|
284
|
+
a.click();
|
|
285
|
+
a.remove();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Styled components
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
const Tray = styled.div`
|
|
294
|
+
display: flex;
|
|
295
|
+
flex-direction: column;
|
|
296
|
+
gap: 8px;
|
|
297
|
+
min-width: 260px;
|
|
298
|
+
max-height: min(70vh, 520px);
|
|
299
|
+
overflow-y: auto;
|
|
300
|
+
overflow-x: hidden;
|
|
301
|
+
padding: 10px;
|
|
302
|
+
background: var(--kerbcast-tray-bg, rgba(0, 0, 0, 0.85));
|
|
303
|
+
border: 1px solid var(--kerbcast-tray-border, rgba(255, 255, 255, 0.3));
|
|
304
|
+
border-radius: 6px;
|
|
305
|
+
color: var(--kerbcast-tray-text, #fff);
|
|
306
|
+
font-size: 12px;
|
|
307
|
+
`;
|
|
308
|
+
|
|
309
|
+
const Empty = styled.div`
|
|
310
|
+
padding: 16px 8px;
|
|
311
|
+
text-align: center;
|
|
312
|
+
color: var(--kerbcast-tray-text-muted, rgba(255, 255, 255, 0.65));
|
|
313
|
+
font-style: italic;
|
|
314
|
+
`;
|
|
315
|
+
|
|
316
|
+
const Notice = styled.div`
|
|
317
|
+
padding: 2px 2px 4px;
|
|
318
|
+
color: var(--kerbcast-tray-text-muted, rgba(255, 255, 255, 0.55));
|
|
319
|
+
font-size: 10.5px;
|
|
320
|
+
font-style: italic;
|
|
321
|
+
letter-spacing: 0.01em;
|
|
322
|
+
`;
|
|
323
|
+
|
|
324
|
+
const ClipList = styled.div`
|
|
325
|
+
display: flex;
|
|
326
|
+
flex-direction: column;
|
|
327
|
+
gap: 8px;
|
|
328
|
+
`;
|
|
329
|
+
|
|
330
|
+
const Clip = styled.div`
|
|
331
|
+
display: flex;
|
|
332
|
+
flex-wrap: wrap;
|
|
333
|
+
align-items: flex-start;
|
|
334
|
+
gap: 8px;
|
|
335
|
+
padding: 6px 0;
|
|
336
|
+
border-top: 1px solid var(--kerbcast-tray-border-subtle, rgba(255, 255, 255, 0.12));
|
|
337
|
+
|
|
338
|
+
&:first-child {
|
|
339
|
+
border-top: none;
|
|
340
|
+
padding-top: 0;
|
|
341
|
+
}
|
|
342
|
+
`;
|
|
343
|
+
|
|
344
|
+
const ClipPreview = styled.video`
|
|
345
|
+
width: 120px;
|
|
346
|
+
aspect-ratio: 16 / 9;
|
|
347
|
+
background: #000;
|
|
348
|
+
border-radius: 4px;
|
|
349
|
+
flex-shrink: 0;
|
|
350
|
+
object-fit: cover;
|
|
351
|
+
`;
|
|
352
|
+
|
|
353
|
+
const ClipPreviewPlaceholder = styled.div`
|
|
354
|
+
width: 120px;
|
|
355
|
+
aspect-ratio: 16 / 9;
|
|
356
|
+
background: rgba(255, 255, 255, 0.05);
|
|
357
|
+
border-radius: 4px;
|
|
358
|
+
flex-shrink: 0;
|
|
359
|
+
`;
|
|
360
|
+
|
|
361
|
+
const ClipMeta = styled.div`
|
|
362
|
+
display: flex;
|
|
363
|
+
flex-direction: column;
|
|
364
|
+
gap: 2px;
|
|
365
|
+
flex: 1 1 140px;
|
|
366
|
+
min-width: 0;
|
|
367
|
+
`;
|
|
368
|
+
|
|
369
|
+
const ClipName = styled.div`
|
|
370
|
+
font-weight: 600;
|
|
371
|
+
letter-spacing: 0.03em;
|
|
372
|
+
overflow: hidden;
|
|
373
|
+
text-overflow: ellipsis;
|
|
374
|
+
white-space: nowrap;
|
|
375
|
+
`;
|
|
376
|
+
|
|
377
|
+
const ClipDetail = styled.div`
|
|
378
|
+
color: var(--kerbcast-tray-text-muted, rgba(255, 255, 255, 0.65));
|
|
379
|
+
font-size: 11px;
|
|
380
|
+
`;
|
|
381
|
+
|
|
382
|
+
const ClipActions = styled.div`
|
|
383
|
+
display: flex;
|
|
384
|
+
flex-direction: column;
|
|
385
|
+
align-items: flex-start;
|
|
386
|
+
gap: 4px;
|
|
387
|
+
flex-shrink: 0;
|
|
388
|
+
`;
|
|
389
|
+
|
|
390
|
+
const trayButtonBase = `
|
|
391
|
+
padding: 3px 8px;
|
|
392
|
+
border-radius: 3px;
|
|
393
|
+
font-size: 11px;
|
|
394
|
+
letter-spacing: 0.03em;
|
|
395
|
+
text-decoration: none;
|
|
396
|
+
cursor: pointer;
|
|
397
|
+
white-space: nowrap;
|
|
398
|
+
`;
|
|
399
|
+
|
|
400
|
+
const TrayButton = styled.button<{ $danger?: boolean }>`
|
|
401
|
+
${trayButtonBase}
|
|
402
|
+
background: transparent;
|
|
403
|
+
border: 1px solid
|
|
404
|
+
${(p) =>
|
|
405
|
+
p.$danger
|
|
406
|
+
? "var(--kerbcast-tray-danger, #ff6b5e)"
|
|
407
|
+
: "var(--kerbcast-accent, #00ff88)"};
|
|
408
|
+
color: ${(p) => (p.$danger ? "var(--kerbcast-tray-danger, #ff6b5e)" : "var(--kerbcast-accent, #00ff88)")};
|
|
409
|
+
|
|
410
|
+
@media (hover: hover) {
|
|
411
|
+
&:hover {
|
|
412
|
+
background: ${(p) =>
|
|
413
|
+
p.$danger ? "rgba(255, 107, 94, 0.15)" : "var(--kerbcast-accent-wash, rgba(0, 255, 136, 0.15))"};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
`;
|
|
417
|
+
|
|
418
|
+
const TrayLink = styled.a`
|
|
419
|
+
${trayButtonBase}
|
|
420
|
+
display: inline-block;
|
|
421
|
+
background: transparent;
|
|
422
|
+
border: 1px solid var(--kerbcast-accent, #00ff88);
|
|
423
|
+
color: var(--kerbcast-accent, #00ff88);
|
|
424
|
+
|
|
425
|
+
@media (hover: hover) {
|
|
426
|
+
&:hover {
|
|
427
|
+
background: var(--kerbcast-accent-wash, rgba(0, 255, 136, 0.15));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
`;
|
|
431
|
+
|
|
432
|
+
const GroupCard = styled.div`
|
|
433
|
+
display: flex;
|
|
434
|
+
flex-direction: column;
|
|
435
|
+
gap: 4px;
|
|
436
|
+
padding: 8px;
|
|
437
|
+
background: var(--kerbcast-tray-surface-raised, rgba(255, 255, 255, 0.04));
|
|
438
|
+
border: 1px solid var(--kerbcast-tray-border-subtle, rgba(255, 255, 255, 0.12));
|
|
439
|
+
border-left: 2px solid var(--kerbcast-accent, #00ff88);
|
|
440
|
+
border-radius: 4px;
|
|
441
|
+
|
|
442
|
+
${Clip} {
|
|
443
|
+
border-top-color: var(--kerbcast-tray-border-subtle, rgba(255, 255, 255, 0.12));
|
|
444
|
+
}
|
|
445
|
+
`;
|
|
446
|
+
|
|
447
|
+
const GroupHeader = styled.div`
|
|
448
|
+
display: flex;
|
|
449
|
+
flex-wrap: wrap;
|
|
450
|
+
align-items: flex-start;
|
|
451
|
+
justify-content: space-between;
|
|
452
|
+
gap: 8px;
|
|
453
|
+
`;
|
|
454
|
+
|
|
455
|
+
const GroupHeaderText = styled.div`
|
|
456
|
+
display: flex;
|
|
457
|
+
flex-direction: column;
|
|
458
|
+
gap: 2px;
|
|
459
|
+
`;
|
|
460
|
+
|
|
461
|
+
const GroupTitle = styled.div`
|
|
462
|
+
font-weight: 600;
|
|
463
|
+
letter-spacing: 0.03em;
|
|
464
|
+
`;
|
|
465
|
+
|
|
466
|
+
const GroupMeta = styled.div`
|
|
467
|
+
color: var(--kerbcast-tray-text-muted, rgba(255, 255, 255, 0.65));
|
|
468
|
+
font-size: 11px;
|
|
469
|
+
`;
|
|
470
|
+
|
|
471
|
+
const GroupActions = styled.div`
|
|
472
|
+
display: flex;
|
|
473
|
+
gap: 6px;
|
|
474
|
+
flex-shrink: 0;
|
|
475
|
+
`;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
3
|
+
import { createClientDisplaySizes } from "./context";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A client stub that only records reportDisplaySize calls. The registry is the
|
|
7
|
+
* per-consumer MAX collapse across every mounted reporter for one flightId, so
|
|
8
|
+
* a spotlight and a tiny avatar of the same camera resolve to one report = max,
|
|
9
|
+
* and a departing big reporter relaxes the stream back down (the v1.6.3
|
|
10
|
+
* "don't pin large" discipline at the client edge).
|
|
11
|
+
*/
|
|
12
|
+
function fakeClient() {
|
|
13
|
+
const reports: Array<{ flightId: number; width: number; height: number }> = [];
|
|
14
|
+
const client = {
|
|
15
|
+
reportDisplaySize: vi.fn(async (flightId: number, width: number, height: number) => {
|
|
16
|
+
reports.push({ flightId, width, height });
|
|
17
|
+
}),
|
|
18
|
+
} as unknown as KerbcastClient;
|
|
19
|
+
return { client, reports };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("createClientDisplaySizes", () => {
|
|
23
|
+
it("reports the measured size for a single reporter", () => {
|
|
24
|
+
const { client, reports } = fakeClient();
|
|
25
|
+
const reg = createClientDisplaySizes(client);
|
|
26
|
+
reg.report(1, "a", 40, 40);
|
|
27
|
+
expect(reports).toEqual([{ flightId: 1, width: 40, height: 40 }]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("collapses two reporters of one camera to a single MAX report", () => {
|
|
31
|
+
const { client, reports } = fakeClient();
|
|
32
|
+
const reg = createClientDisplaySizes(client);
|
|
33
|
+
reg.report(1, "small", 40, 40);
|
|
34
|
+
reg.report(1, "big", 512, 512);
|
|
35
|
+
// 40 first, then bumped to the max 512 when the big one mounts.
|
|
36
|
+
expect(reports).toEqual([
|
|
37
|
+
{ flightId: 1, width: 40, height: 40 },
|
|
38
|
+
{ flightId: 1, width: 512, height: 512 },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("maxes width and height independently across reporters", () => {
|
|
43
|
+
const { client, reports } = fakeClient();
|
|
44
|
+
const reg = createClientDisplaySizes(client);
|
|
45
|
+
reg.report(1, "a", 640, 360);
|
|
46
|
+
reg.report(1, "b", 320, 400);
|
|
47
|
+
expect(reports.at(-1)).toEqual({ flightId: 1, width: 640, height: 400 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not re-report when the max is unchanged", () => {
|
|
51
|
+
const { client, reports } = fakeClient();
|
|
52
|
+
const reg = createClientDisplaySizes(client);
|
|
53
|
+
reg.report(1, "big", 512, 512);
|
|
54
|
+
reg.report(1, "small", 40, 40); // below current max -> no new report
|
|
55
|
+
reg.report(1, "small", 44, 44); // still below -> no new report
|
|
56
|
+
expect(reports).toEqual([{ flightId: 1, width: 512, height: 512 }]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("relaxes to the next-largest reporter when the big one departs", () => {
|
|
60
|
+
const { client, reports } = fakeClient();
|
|
61
|
+
const reg = createClientDisplaySizes(client);
|
|
62
|
+
reg.report(1, "small", 40, 40);
|
|
63
|
+
reg.report(1, "big", 512, 512);
|
|
64
|
+
reg.clear(1, "big");
|
|
65
|
+
expect(reports.at(-1)).toEqual({ flightId: 1, width: 40, height: 40 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("sends nothing more once the last reporter departs (backend forget clears it)", () => {
|
|
69
|
+
const { client, reports } = fakeClient();
|
|
70
|
+
const reg = createClientDisplaySizes(client);
|
|
71
|
+
reg.report(1, "a", 40, 40);
|
|
72
|
+
const countBefore = reports.length;
|
|
73
|
+
reg.clear(1, "a");
|
|
74
|
+
expect(reports.length).toBe(countBefore); // no extra report on empty
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("keeps separate maxes per flightId", () => {
|
|
78
|
+
const { client, reports } = fakeClient();
|
|
79
|
+
const reg = createClientDisplaySizes(client);
|
|
80
|
+
reg.report(1, "a", 40, 40);
|
|
81
|
+
reg.report(2, "b", 512, 512);
|
|
82
|
+
expect(reports).toEqual([
|
|
83
|
+
{ flightId: 1, width: 40, height: 40 },
|
|
84
|
+
{ flightId: 2, width: 512, height: 512 },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("clearing an unknown reporter is a no-op", () => {
|
|
89
|
+
const { client, reports } = fakeClient();
|
|
90
|
+
const reg = createClientDisplaySizes(client);
|
|
91
|
+
reg.clear(99, "ghost");
|
|
92
|
+
expect(reports).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
});
|
package/src/context.tsx
CHANGED
|
@@ -46,6 +46,78 @@ export function createClientSubscriptions(
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Display-size reporting seam
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Collects each mounted feed's self-measured display size and drives the
|
|
55
|
+
* client's per-consumer `reportDisplaySize`. Multiple feeds can show the SAME
|
|
56
|
+
* camera at once (e.g. a tiny avatar and a spotlight of the same kerbal); the
|
|
57
|
+
* sidecar keys the auto-resolution max per CONSUMER, so those would clobber
|
|
58
|
+
* each other last-writer-wins. This collapses them at the client edge: it
|
|
59
|
+
* keeps the MAX display size across every mounted reporter for a flightId and
|
|
60
|
+
* sends one report per max-change. Co-located with the subscription refcount
|
|
61
|
+
* (same per-flightId lifetime) so one object owns everything this client is
|
|
62
|
+
* doing with camera N.
|
|
63
|
+
*/
|
|
64
|
+
export interface KerbcastDisplaySizes {
|
|
65
|
+
/** Register/update a reporter's measured size for a camera. */
|
|
66
|
+
report(flightId: number, instanceId: string, width: number, height: number): void;
|
|
67
|
+
/** Deregister a reporter (on unmount / flightId change). */
|
|
68
|
+
clear(flightId: number, instanceId: string): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Build the default per-flightId MAX display-size registry for a client. */
|
|
72
|
+
export function createClientDisplaySizes(
|
|
73
|
+
client: KerbcastClient,
|
|
74
|
+
): KerbcastDisplaySizes {
|
|
75
|
+
// flightId -> (instanceId -> measured size)
|
|
76
|
+
const perCamera = new Map<number, Map<string, { w: number; h: number }>>();
|
|
77
|
+
// Last size reported to the sidecar per flightId, so we send only on change.
|
|
78
|
+
const lastSent = new Map<number, { w: number; h: number }>();
|
|
79
|
+
|
|
80
|
+
function recompute(flightId: number): void {
|
|
81
|
+
const instances = perCamera.get(flightId);
|
|
82
|
+
if (!instances || instances.size === 0) {
|
|
83
|
+
// No reporters left. Drop bookkeeping and send nothing: the subscription
|
|
84
|
+
// release (unsubscribe -> sidecar remove_track -> forget_display_size)
|
|
85
|
+
// clears the sidecar side. Sending a spurious size here would fight it.
|
|
86
|
+
perCamera.delete(flightId);
|
|
87
|
+
lastSent.delete(flightId);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
let maxW = 0;
|
|
91
|
+
let maxH = 0;
|
|
92
|
+
for (const { w, h } of instances.values()) {
|
|
93
|
+
if (w > maxW) maxW = w;
|
|
94
|
+
if (h > maxH) maxH = h;
|
|
95
|
+
}
|
|
96
|
+
const prev = lastSent.get(flightId);
|
|
97
|
+
if (prev && prev.w === maxW && prev.h === maxH) return;
|
|
98
|
+
lastSent.set(flightId, { w: maxW, h: maxH });
|
|
99
|
+
client.reportDisplaySize(flightId, maxW, maxH).catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
report(flightId: number, instanceId: string, width: number, height: number): void {
|
|
104
|
+
let instances = perCamera.get(flightId);
|
|
105
|
+
if (!instances) {
|
|
106
|
+
instances = new Map();
|
|
107
|
+
perCamera.set(flightId, instances);
|
|
108
|
+
}
|
|
109
|
+
instances.set(instanceId, { w: width, h: height });
|
|
110
|
+
recompute(flightId);
|
|
111
|
+
},
|
|
112
|
+
clear(flightId: number, instanceId: string): void {
|
|
113
|
+
const instances = perCamera.get(flightId);
|
|
114
|
+
if (!instances) return;
|
|
115
|
+
instances.delete(instanceId);
|
|
116
|
+
recompute(flightId);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
49
121
|
// ---------------------------------------------------------------------------
|
|
50
122
|
// Context shape
|
|
51
123
|
// ---------------------------------------------------------------------------
|
|
@@ -53,6 +125,7 @@ export function createClientSubscriptions(
|
|
|
53
125
|
interface KerbcastContextValue {
|
|
54
126
|
client: KerbcastClient;
|
|
55
127
|
subscriptions: KerbcastSubscriptions;
|
|
128
|
+
displaySizes: KerbcastDisplaySizes;
|
|
56
129
|
}
|
|
57
130
|
|
|
58
131
|
const KerbcastContext = createContext<KerbcastContextValue | null>(null);
|
|
@@ -65,6 +138,8 @@ export interface KerbcastProviderProps {
|
|
|
65
138
|
client: KerbcastClient;
|
|
66
139
|
/** Override the subscription manager. Defaults to a refcounted per-client impl. */
|
|
67
140
|
subscriptions?: KerbcastSubscriptions;
|
|
141
|
+
/** Override the display-size registry. Defaults to a per-flightId MAX impl. */
|
|
142
|
+
displaySizes?: KerbcastDisplaySizes;
|
|
68
143
|
children: ReactNode;
|
|
69
144
|
}
|
|
70
145
|
|
|
@@ -75,6 +150,7 @@ export interface KerbcastProviderProps {
|
|
|
75
150
|
export function KerbcastProvider({
|
|
76
151
|
client,
|
|
77
152
|
subscriptions: subscriptionsProp,
|
|
153
|
+
displaySizes: displaySizesProp,
|
|
78
154
|
children,
|
|
79
155
|
}: KerbcastProviderProps): React.JSX.Element {
|
|
80
156
|
// Create default subscriptions once per client. A ref so the object is
|
|
@@ -84,13 +160,20 @@ export function KerbcastProvider({
|
|
|
84
160
|
defaultSubsRef.current = createClientSubscriptions(client);
|
|
85
161
|
}
|
|
86
162
|
|
|
163
|
+
// Same for the display-size registry: one per client, stable across renders.
|
|
164
|
+
const defaultSizesRef = useRef<KerbcastDisplaySizes | null>(null);
|
|
165
|
+
if (defaultSizesRef.current === null) {
|
|
166
|
+
defaultSizesRef.current = createClientDisplaySizes(client);
|
|
167
|
+
}
|
|
168
|
+
|
|
87
169
|
const value = useMemo(
|
|
88
170
|
() => ({
|
|
89
171
|
client,
|
|
90
172
|
subscriptions: subscriptionsProp ?? defaultSubsRef.current!,
|
|
173
|
+
displaySizes: displaySizesProp ?? defaultSizesRef.current!,
|
|
91
174
|
}),
|
|
92
175
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
93
|
-
[client, subscriptionsProp],
|
|
176
|
+
[client, subscriptionsProp, displaySizesProp],
|
|
94
177
|
);
|
|
95
178
|
|
|
96
179
|
return (
|
|
@@ -127,3 +210,17 @@ export function useKerbcastSubscriptions(): KerbcastSubscriptions {
|
|
|
127
210
|
}
|
|
128
211
|
return ctx.subscriptions;
|
|
129
212
|
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Return the `KerbcastDisplaySizes` registry from the nearest
|
|
216
|
+
* `KerbcastProvider`. Throws if called outside a provider.
|
|
217
|
+
*/
|
|
218
|
+
export function useKerbcastDisplaySizes(): KerbcastDisplaySizes {
|
|
219
|
+
const ctx = useContext(KerbcastContext);
|
|
220
|
+
if (!ctx) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
"useKerbcastDisplaySizes must be used inside a KerbcastProvider",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return ctx.displaySizes;
|
|
226
|
+
}
|