@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,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
|
+
`;
|
|
@@ -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
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,9 @@ export { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
|
12
12
|
export { useKerbcastClock } from "./hooks/useKerbcastClock";
|
|
13
13
|
export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
14
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";
|
|
15
18
|
export { StandbyIcon } from "./StandbyIcon";
|
|
16
19
|
export { buildCameraLabeler } from "./cameraLabels";
|
|
17
20
|
export type { LabelableCamera } from "./cameraLabels";
|
|
@@ -19,5 +22,9 @@ export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
|
|
|
19
22
|
export type { CameraLifecycle } from "./lifecycle";
|
|
20
23
|
export { CameraFeed } from "./CameraFeed";
|
|
21
24
|
export type { CameraFeedHandle, CameraFeedProps, CameraStreamHook, FeedAction, } from "./CameraFeed";
|
|
25
|
+
export { FeedActionBar } from "./FeedActionBar";
|
|
26
|
+
export type { FeedActionBarEntry } from "./FeedActionBar";
|
|
22
27
|
export { KerbalFaceFeed } from "./KerbalFaceFeed";
|
|
23
28
|
export type { KerbalFaceFeedProps, KerbalFeedState } from "./KerbalFaceFeed";
|
|
29
|
+
export { RecordingsTray } from "./RecordingsTray";
|
|
30
|
+
export type { RecordingsTrayProps } from "./RecordingsTray";
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,9 @@ export { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
|
13
13
|
export { useKerbcastClock } from "./hooks/useKerbcastClock";
|
|
14
14
|
export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
15
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";
|
|
16
19
|
// Shared standby glyph (used by CameraFeed + downstream dashboards)
|
|
17
20
|
export { StandbyIcon } from "./StandbyIcon";
|
|
18
21
|
// Camera label utilities
|
|
@@ -21,5 +24,11 @@ export { buildCameraLabeler } from "./cameraLabels";
|
|
|
21
24
|
export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
|
|
22
25
|
// CameraFeed component
|
|
23
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";
|
|
24
31
|
// KerbalFaceFeed — shared single-kerbal-face primitive
|
|
25
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
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
8
|
+
const MENU_GAP = 4; // trigger-to-menu spacing
|
|
9
|
+
const MENU_EDGE = 8; // minimum inset from the viewport edge
|
|
10
|
+
/*
|
|
11
|
+
* Fixed-position style for a portaled menu, anchored to its trigger button.
|
|
12
|
+
* Opens downward by default; flips above the trigger when there is not
|
|
13
|
+
* enough room below but there is above, otherwise clamps to the viewport.
|
|
14
|
+
*/
|
|
15
|
+
export function computeMenuPosition(trigger, anchor) {
|
|
16
|
+
const rect = trigger.getBoundingClientRect();
|
|
17
|
+
const vw = window.innerWidth;
|
|
18
|
+
const vh = window.innerHeight;
|
|
19
|
+
const width = Math.min(anchor.maxWidth, vw - 2 * MENU_EDGE);
|
|
20
|
+
/* Inline offset from the aligned viewport edge, clamped so a full-width
|
|
21
|
+
menu still fits inside the opposite edge. */
|
|
22
|
+
const inset = Math.max(MENU_EDGE, vw - width - MENU_EDGE);
|
|
23
|
+
const inline = anchor.align === "start"
|
|
24
|
+
? { left: Math.min(Math.max(rect.left, MENU_EDGE), inset) }
|
|
25
|
+
: { right: Math.min(Math.max(vw - rect.right, MENU_EDGE), inset) };
|
|
26
|
+
const menuH = Math.min(0.4 * vh, anchor.maxHeight);
|
|
27
|
+
const fitsBelow = rect.bottom + MENU_GAP + menuH <= vh - MENU_EDGE;
|
|
28
|
+
const fitsAbove = rect.top - MENU_GAP - menuH >= MENU_EDGE;
|
|
29
|
+
if (!fitsBelow && fitsAbove) {
|
|
30
|
+
return { ...inline, bottom: vh - rect.top + MENU_GAP };
|
|
31
|
+
}
|
|
32
|
+
const top = Math.min(rect.bottom + MENU_GAP, Math.max(MENU_EDGE, vh - MENU_EDGE - menuH));
|
|
33
|
+
return { ...inline, top };
|
|
34
|
+
}
|
|
35
|
+
/*
|
|
36
|
+
* Shared behaviour for a dropdown portaled to document.body: fixed position
|
|
37
|
+
* computed from the trigger's rect (so tile overflow cannot clip it),
|
|
38
|
+
* re-anchored on window resize/scroll while open, Escape to close with focus
|
|
39
|
+
* returned to the trigger, and portal-aware outside-pointer-down dismissal
|
|
40
|
+
* (menuRef points at the portaled menu, so clicks inside it stay "inside").
|
|
41
|
+
* One menu at a time falls out of the dismissal: opening another menu's
|
|
42
|
+
* trigger is an outside press for this one.
|
|
43
|
+
*/
|
|
44
|
+
export function usePortalMenu({ maxWidth, maxHeight, align }) {
|
|
45
|
+
const [open, setOpen] = useState(false);
|
|
46
|
+
const [position, setPosition] = useState(null);
|
|
47
|
+
const menuRef = useRef(null);
|
|
48
|
+
const triggerRef = useRef(null);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!open) {
|
|
51
|
+
setPosition(null);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const update = () => {
|
|
55
|
+
const trigger = triggerRef.current;
|
|
56
|
+
if (trigger) {
|
|
57
|
+
setPosition(computeMenuPosition(trigger, { maxWidth, maxHeight, align }));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
update();
|
|
61
|
+
window.addEventListener("resize", update);
|
|
62
|
+
window.addEventListener("scroll", update, true);
|
|
63
|
+
return () => {
|
|
64
|
+
window.removeEventListener("resize", update);
|
|
65
|
+
window.removeEventListener("scroll", update, true);
|
|
66
|
+
};
|
|
67
|
+
}, [open, maxWidth, maxHeight, align]);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!open)
|
|
70
|
+
return;
|
|
71
|
+
const onKeyDown = (e) => {
|
|
72
|
+
if (e.key === "Escape") {
|
|
73
|
+
e.stopPropagation();
|
|
74
|
+
setOpen(false);
|
|
75
|
+
triggerRef.current?.focus();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const onPointerDown = (e) => {
|
|
79
|
+
if (!menuRef.current?.contains(e.target) &&
|
|
80
|
+
!triggerRef.current?.contains(e.target)) {
|
|
81
|
+
setOpen(false);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
document.addEventListener("keydown", onKeyDown);
|
|
85
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
86
|
+
return () => {
|
|
87
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
88
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
89
|
+
};
|
|
90
|
+
}, [open]);
|
|
91
|
+
const toggle = useCallback(() => setOpen((v) => !v), []);
|
|
92
|
+
/** Close after an item pick, returning focus to the trigger. */
|
|
93
|
+
const close = useCallback(() => {
|
|
94
|
+
setOpen(false);
|
|
95
|
+
triggerRef.current?.focus();
|
|
96
|
+
}, []);
|
|
97
|
+
return { open, toggle, close, position, menuRef, triggerRef };
|
|
98
|
+
}
|
package/dist/timing.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Monotonic clock for elapsed-timer math. Falls back to Date.now under jsdom. */
|
|
2
|
+
export declare function nowMs(): number;
|
|
3
|
+
/** Formats a millisecond duration as m:ss (e.g. "REC 1:05", "Recording 3 feeds - 0:42"). */
|
|
4
|
+
export declare function formatElapsed(ms: number): string;
|
package/dist/timing.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared elapsed-timer helpers for REC-style live-duration displays: the
|
|
3
|
+
* per-feed REC badge (CameraFeed) and the REC+ grouped-recording toolbar
|
|
4
|
+
* (a host's own RecGroupBar). Framework-free so both places read the same
|
|
5
|
+
* clock and format the same way instead of drifting apart.
|
|
6
|
+
*/
|
|
7
|
+
/** Monotonic clock for elapsed-timer math. Falls back to Date.now under jsdom. */
|
|
8
|
+
export function nowMs() {
|
|
9
|
+
return typeof performance !== "undefined" && typeof performance.now === "function"
|
|
10
|
+
? performance.now()
|
|
11
|
+
: Date.now();
|
|
12
|
+
}
|
|
13
|
+
/** Formats a millisecond duration as m:ss (e.g. "REC 1:05", "Recording 3 feeds - 0:42"). */
|
|
14
|
+
export function formatElapsed(ms) {
|
|
15
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
16
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
17
|
+
const seconds = totalSeconds % 60;
|
|
18
|
+
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ksp-gonogo/kerbcast-react",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "React components and hooks for kerbcast camera feeds.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jon Pepler",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"access": "public"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@ksp-gonogo/kerbcast": "^1.
|
|
48
|
+
"@ksp-gonogo/kerbcast": "^1.8.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"react": "^18 || ^19",
|