@ksp-gonogo/kerbcast-react 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/CameraFeed.d.ts +32 -16
- package/dist/CameraFeed.js +259 -169
- package/dist/FeedActionBar.d.ts +133 -0
- package/dist/FeedActionBar.js +225 -0
- package/dist/KerbalFaceFeed.d.ts +1 -1
- package/dist/KerbalFaceFeed.js +12 -19
- package/dist/RecordingsTray.d.ts +37 -0
- package/dist/RecordingsTray.js +279 -0
- package/dist/hooks/useRecordings.d.ts +35 -0
- package/dist/hooks/useRecordings.js +33 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +9 -0
- 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 +545 -0
- package/src/CameraFeed.tsx +421 -309
- package/src/FeedActionBar.test.ts +196 -0
- package/src/FeedActionBar.test.tsx +215 -0
- package/src/FeedActionBar.tsx +391 -0
- package/src/KerbalFaceFeed.tsx +13 -35
- package/src/RecordingsTray.test.tsx +288 -0
- package/src/RecordingsTray.tsx +475 -0
- 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/index.ts +15 -0
- 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,117 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Regression test for the shared-state bug: two independent useRecordings()
|
|
3
|
+
* call sites over the same client must see the same controller state. Before
|
|
4
|
+
* the observable-store fix, each call site held its own useState mirror, so
|
|
5
|
+
* a clip started/stopped through one component's hook was invisible to
|
|
6
|
+
* another -- the exact scenario where a tile's own REC button starts a clip
|
|
7
|
+
* a RecordingsTray elsewhere can never reach (data loss).
|
|
8
|
+
*
|
|
9
|
+
* Must fail on the old per-instance-useState hook and pass once
|
|
10
|
+
* useRecordings consumes RecordingController via useSyncExternalStore.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
14
|
+
import { RecordingController, type RecordingClient } from "@ksp-gonogo/kerbcast";
|
|
15
|
+
import { installDomStubs, StubMediaRecorder } from "@ksp-gonogo/kerbcast/testing";
|
|
16
|
+
import { act, cleanup, render } from "@testing-library/react";
|
|
17
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
18
|
+
import { KerbcastProvider } from "../context";
|
|
19
|
+
import { useRecordings, type RecordingsStore } from "./useRecordings";
|
|
20
|
+
|
|
21
|
+
installDomStubs();
|
|
22
|
+
|
|
23
|
+
function liveStream(): MediaStream {
|
|
24
|
+
const track = { kind: "video" } as MediaStreamTrack;
|
|
25
|
+
return {
|
|
26
|
+
getVideoTracks: () => [track],
|
|
27
|
+
getTracks: () => [track],
|
|
28
|
+
} as unknown as MediaStream;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Fake RecordingClient with a settable clock; enough for the controller. */
|
|
32
|
+
class FakeRecordingClient implements RecordingClient {
|
|
33
|
+
captureUt: number | null = 100;
|
|
34
|
+
camera(_flightId: number): { readonly mediaStream: MediaStream | null } {
|
|
35
|
+
return { mediaStream: liveStream() };
|
|
36
|
+
}
|
|
37
|
+
get clock(): { readonly captureUt: number | null } {
|
|
38
|
+
return { captureUt: this.captureUt };
|
|
39
|
+
}
|
|
40
|
+
on(_event: "settings-change", _handler: (data: unknown) => void): () => void {
|
|
41
|
+
return () => {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fakeClient(): KerbcastClient {
|
|
46
|
+
const controller = new RecordingController(new FakeRecordingClient(), {
|
|
47
|
+
loadTrimmer: () => Promise.resolve(null),
|
|
48
|
+
});
|
|
49
|
+
return { recording: controller } as unknown as KerbcastClient;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let storeA: RecordingsStore | undefined;
|
|
53
|
+
let storeB: RecordingsStore | undefined;
|
|
54
|
+
|
|
55
|
+
/* Two components mounted side by side, each calling useRecordings() on its
|
|
56
|
+
own, standing in for a CameraFeed's own REC control and a separately
|
|
57
|
+
mounted RecordingsTray. */
|
|
58
|
+
function ProbeA(): null {
|
|
59
|
+
storeA = useRecordings();
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
function ProbeB(): null {
|
|
63
|
+
storeB = useRecordings();
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function mount(client: KerbcastClient): void {
|
|
68
|
+
render(
|
|
69
|
+
<KerbcastProvider client={client}>
|
|
70
|
+
<ProbeA />
|
|
71
|
+
<ProbeB />
|
|
72
|
+
</KerbcastProvider>,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
cleanup();
|
|
78
|
+
storeA = undefined;
|
|
79
|
+
storeB = undefined;
|
|
80
|
+
StubMediaRecorder.supportedTypes = null;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("useRecordings shared store", () => {
|
|
84
|
+
it("makes an in-progress recording started via one call site visible to another", () => {
|
|
85
|
+
mount(fakeClient());
|
|
86
|
+
|
|
87
|
+
let id = "";
|
|
88
|
+
act(() => {
|
|
89
|
+
id = storeA!.start(42);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(storeB?.active).toHaveLength(1);
|
|
93
|
+
expect(storeB?.active[0]).toMatchObject({ flightId: 42, recordingId: id });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("makes a clip finished via one call site's stop visible in another's recordings", async () => {
|
|
97
|
+
mount(fakeClient());
|
|
98
|
+
|
|
99
|
+
let id = "";
|
|
100
|
+
act(() => {
|
|
101
|
+
id = storeA!.start(42);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await act(async () => {
|
|
105
|
+
await storeA!.stop(id);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(storeA?.active).toHaveLength(0);
|
|
109
|
+
expect(storeA?.recordings).toHaveLength(1);
|
|
110
|
+
|
|
111
|
+
/* storeB never called start/stop itself: without the fix, this clip is
|
|
112
|
+
unreachable through it, which is exactly the data-loss bug. */
|
|
113
|
+
expect(storeB?.active).toHaveLength(0);
|
|
114
|
+
expect(storeB?.recordings).toHaveLength(1);
|
|
115
|
+
expect(storeB?.recordings[0].recordingId).toBe(id);
|
|
116
|
+
});
|
|
117
|
+
});
|