@ksp-gonogo/kerbcast-react 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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)} &middot; {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 &middot; {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
+ });