@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,217 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* useRecordings store.
|
|
3
|
+
*
|
|
4
|
+
* Renders the hook through a real KerbcastProvider, backed by a fake client
|
|
5
|
+
* whose `recording` is a real RecordingController over a fake clock/camera
|
|
6
|
+
* surface (the SDK's own controller path is unit-tested in the typescript
|
|
7
|
+
* package). Asserts the store reflects start -> stop into `recordings` /
|
|
8
|
+
* `groups` / `active`, keeps grouped members out of the singles list, and
|
|
9
|
+
* clears on discard.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
13
|
+
import {
|
|
14
|
+
RecordingController,
|
|
15
|
+
type RecordingClient,
|
|
16
|
+
} from "@ksp-gonogo/kerbcast";
|
|
17
|
+
import { installDomStubs, StubMediaRecorder } from "@ksp-gonogo/kerbcast/testing";
|
|
18
|
+
import { act, cleanup, render } from "@testing-library/react";
|
|
19
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
20
|
+
import { KerbcastProvider } from "../context";
|
|
21
|
+
import { useRecordings, type RecordingsStore } from "./useRecordings";
|
|
22
|
+
|
|
23
|
+
installDomStubs();
|
|
24
|
+
|
|
25
|
+
function liveStream(): MediaStream {
|
|
26
|
+
const track = { kind: "video" } as MediaStreamTrack;
|
|
27
|
+
return {
|
|
28
|
+
getVideoTracks: () => [track],
|
|
29
|
+
getTracks: () => [track],
|
|
30
|
+
} as unknown as MediaStream;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Fake RecordingClient with a settable clock; enough for the controller. */
|
|
34
|
+
class FakeRecordingClient implements RecordingClient {
|
|
35
|
+
captureUt: number | null = 100;
|
|
36
|
+
camera(_flightId: number): { readonly mediaStream: MediaStream | null } {
|
|
37
|
+
return { mediaStream: liveStream() };
|
|
38
|
+
}
|
|
39
|
+
get clock(): { readonly captureUt: number | null } {
|
|
40
|
+
return { captureUt: this.captureUt };
|
|
41
|
+
}
|
|
42
|
+
on(_event: "settings-change", _handler: (data: unknown) => void): () => void {
|
|
43
|
+
return () => {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A KerbcastClient stand-in exposing only what the hook reads: `.recording`.
|
|
48
|
+
* The trimmer loader is stubbed to null so grouped stops stay hermetic (the
|
|
49
|
+
* physical-trim path itself is covered in the typescript package). */
|
|
50
|
+
function fakeClient(): KerbcastClient {
|
|
51
|
+
const controller = new RecordingController(new FakeRecordingClient(), {
|
|
52
|
+
loadTrimmer: () => Promise.resolve(null),
|
|
53
|
+
});
|
|
54
|
+
return { recording: controller } as unknown as KerbcastClient;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let store: RecordingsStore | undefined;
|
|
58
|
+
function Probe(): null {
|
|
59
|
+
store = useRecordings();
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mount(client: KerbcastClient): void {
|
|
64
|
+
render(
|
|
65
|
+
<KerbcastProvider client={client}>
|
|
66
|
+
<Probe />
|
|
67
|
+
</KerbcastProvider>,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
cleanup();
|
|
73
|
+
store = undefined;
|
|
74
|
+
StubMediaRecorder.supportedTypes = null;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("useRecordings", () => {
|
|
78
|
+
it("starts empty", () => {
|
|
79
|
+
mount(fakeClient());
|
|
80
|
+
expect(store?.recordings).toEqual([]);
|
|
81
|
+
expect(store?.groups).toEqual([]);
|
|
82
|
+
expect(store?.active).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("tracks an active recording, then lands the clip in recordings on stop", async () => {
|
|
86
|
+
mount(fakeClient());
|
|
87
|
+
|
|
88
|
+
let id = "";
|
|
89
|
+
act(() => {
|
|
90
|
+
id = store!.start(42);
|
|
91
|
+
});
|
|
92
|
+
expect(store?.active).toHaveLength(1);
|
|
93
|
+
expect(store?.active[0]).toMatchObject({ flightId: 42, recordingId: id });
|
|
94
|
+
|
|
95
|
+
await act(async () => {
|
|
96
|
+
await store!.stop(id);
|
|
97
|
+
});
|
|
98
|
+
expect(store?.active).toHaveLength(0);
|
|
99
|
+
expect(store?.recordings).toHaveLength(1);
|
|
100
|
+
expect(store?.recordings[0].recordingId).toBe(id);
|
|
101
|
+
expect(store?.recordings[0].flightId).toBe(42);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("keeps grouped clips out of the singles list and in groups", async () => {
|
|
105
|
+
mount(fakeClient());
|
|
106
|
+
|
|
107
|
+
let groupId = "";
|
|
108
|
+
act(() => {
|
|
109
|
+
groupId = store!.startGroup([1, 2]);
|
|
110
|
+
});
|
|
111
|
+
expect(store?.active).toHaveLength(2);
|
|
112
|
+
expect(store?.active.every((a) => a.groupId === groupId)).toBe(true);
|
|
113
|
+
|
|
114
|
+
await act(async () => {
|
|
115
|
+
await store!.stopGroup(groupId);
|
|
116
|
+
});
|
|
117
|
+
expect(store?.active).toHaveLength(0);
|
|
118
|
+
expect(store?.recordings).toHaveLength(0);
|
|
119
|
+
expect(store?.groups).toHaveLength(1);
|
|
120
|
+
expect(store?.groups[0].recordings).toHaveLength(2);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("gives each active group member the real controller-minted recordingId, not a fabricated one", () => {
|
|
124
|
+
const client = fakeClient();
|
|
125
|
+
mount(client);
|
|
126
|
+
|
|
127
|
+
let groupId = "";
|
|
128
|
+
act(() => {
|
|
129
|
+
groupId = store!.startGroup([1, 2]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
for (const member of store!.active) {
|
|
133
|
+
expect(member.groupId).toBe(groupId);
|
|
134
|
+
/* Not the old `${groupId}:${flightId}` fabrication. */
|
|
135
|
+
expect(member.recordingId).not.toBe(`${groupId}:${member.flightId}`);
|
|
136
|
+
/* A real id the controller actually tracks. */
|
|
137
|
+
expect(client.recording.fetchRecording(member.recordingId)).toBeUndefined();
|
|
138
|
+
expect(client.recording.isRecording(member.flightId)).toBe(true);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("cancels a single active group member without disturbing the rest of the group", async () => {
|
|
143
|
+
const client = fakeClient();
|
|
144
|
+
mount(client);
|
|
145
|
+
|
|
146
|
+
let groupId = "";
|
|
147
|
+
act(() => {
|
|
148
|
+
groupId = store!.startGroup([1, 2]);
|
|
149
|
+
});
|
|
150
|
+
const [first, second] = store!.active;
|
|
151
|
+
|
|
152
|
+
await act(async () => {
|
|
153
|
+
await store!.stop(first.recordingId);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/* The cancelled member's feed actually unlocks. */
|
|
157
|
+
expect(client.recording.isRecording(first.flightId)).toBe(false);
|
|
158
|
+
/* The other member is untouched: still active, still recording. */
|
|
159
|
+
expect(client.recording.isRecording(second.flightId)).toBe(true);
|
|
160
|
+
expect(store?.active).toHaveLength(1);
|
|
161
|
+
expect(store?.active[0].recordingId).toBe(second.recordingId);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("discards a single active group member without disturbing the rest of the group", () => {
|
|
165
|
+
const client = fakeClient();
|
|
166
|
+
mount(client);
|
|
167
|
+
|
|
168
|
+
let groupId = "";
|
|
169
|
+
act(() => {
|
|
170
|
+
groupId = store!.startGroup([1, 2]);
|
|
171
|
+
});
|
|
172
|
+
const [first, second] = store!.active;
|
|
173
|
+
|
|
174
|
+
act(() => {
|
|
175
|
+
store!.discard(first.recordingId);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(client.recording.isRecording(first.flightId)).toBe(false);
|
|
179
|
+
expect(client.recording.isRecording(second.flightId)).toBe(true);
|
|
180
|
+
expect(store?.active).toHaveLength(1);
|
|
181
|
+
expect(store?.active[0].recordingId).toBe(second.recordingId);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("discards a finished single clip", async () => {
|
|
185
|
+
mount(fakeClient());
|
|
186
|
+
let id = "";
|
|
187
|
+
act(() => {
|
|
188
|
+
id = store!.start(7);
|
|
189
|
+
});
|
|
190
|
+
await act(async () => {
|
|
191
|
+
await store!.stop(id);
|
|
192
|
+
});
|
|
193
|
+
expect(store?.recordings).toHaveLength(1);
|
|
194
|
+
|
|
195
|
+
act(() => {
|
|
196
|
+
store!.discard(id);
|
|
197
|
+
});
|
|
198
|
+
expect(store?.recordings).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("discards a finished grouped set", async () => {
|
|
202
|
+
mount(fakeClient());
|
|
203
|
+
let groupId = "";
|
|
204
|
+
act(() => {
|
|
205
|
+
groupId = store!.startGroup([1, 2]);
|
|
206
|
+
});
|
|
207
|
+
await act(async () => {
|
|
208
|
+
await store!.stopGroup(groupId);
|
|
209
|
+
});
|
|
210
|
+
expect(store?.groups).toHaveLength(1);
|
|
211
|
+
|
|
212
|
+
act(() => {
|
|
213
|
+
store!.discardGroup(groupId);
|
|
214
|
+
});
|
|
215
|
+
expect(store?.groups).toHaveLength(0);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
ActiveRecordingInfo,
|
|
4
|
+
GroupedRecordingHandle,
|
|
5
|
+
RecordingHandle,
|
|
6
|
+
StartRecordingOptions,
|
|
7
|
+
StopGroupedRecordingOptions,
|
|
8
|
+
} from "@ksp-gonogo/kerbcast";
|
|
9
|
+
import { useKerbcastClient } from "../context";
|
|
10
|
+
|
|
11
|
+
/* Re-exported for compatibility: consumers importing ActiveRecordingInfo from
|
|
12
|
+
this module keep working now that the SDK owns the definition. */
|
|
13
|
+
export type { ActiveRecordingInfo };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Reactive recordings store over the SDK's `client.recording` controller.
|
|
17
|
+
*
|
|
18
|
+
* Holds the in-session, in-memory clips: standalone singles in `recordings`,
|
|
19
|
+
* synchronised sets in `groups`, and the still-running ones in `active`. This
|
|
20
|
+
* is the headless data layer the recordings tray + REC controls sit on; it
|
|
21
|
+
* owns no chrome. All state is in-memory, so a page reload discards it (the UI
|
|
22
|
+
* guards navigation while unsaved clips exist).
|
|
23
|
+
*/
|
|
24
|
+
export interface RecordingsStore {
|
|
25
|
+
/** Finished standalone (non-grouped) clips, newest last. */
|
|
26
|
+
recordings: RecordingHandle[];
|
|
27
|
+
/** Finished grouped sets, newest last. */
|
|
28
|
+
groups: GroupedRecordingHandle[];
|
|
29
|
+
/** Recordings currently in progress (single + grouped members). */
|
|
30
|
+
active: ActiveRecordingInfo[];
|
|
31
|
+
/** Whether a feed currently has a recording in progress. */
|
|
32
|
+
isRecording(flightId: number): boolean;
|
|
33
|
+
|
|
34
|
+
start(flightId: number, opts?: StartRecordingOptions): string;
|
|
35
|
+
stop(recordingId: string): Promise<RecordingHandle>;
|
|
36
|
+
startGroup(flightIds: number[], opts?: StartRecordingOptions): string;
|
|
37
|
+
stopGroup(
|
|
38
|
+
groupId: string,
|
|
39
|
+
opts?: StopGroupedRecordingOptions,
|
|
40
|
+
): Promise<GroupedRecordingHandle>;
|
|
41
|
+
discard(recordingId: string): void;
|
|
42
|
+
discardGroup(groupId: string): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Subscribe a component to the recording store for the context client. The
|
|
47
|
+
* controller itself is the single source of truth for every call site;
|
|
48
|
+
* `useSyncExternalStore` re-renders this component whenever the controller's
|
|
49
|
+
* snapshot changes, no matter which call site (or which component) drove the
|
|
50
|
+
* change.
|
|
51
|
+
*/
|
|
52
|
+
export function useRecordings(): RecordingsStore {
|
|
53
|
+
const client = useKerbcastClient();
|
|
54
|
+
const controller = client.recording;
|
|
55
|
+
|
|
56
|
+
const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
|
|
57
|
+
|
|
58
|
+
const start = useCallback(
|
|
59
|
+
(flightId: number, opts?: StartRecordingOptions): string =>
|
|
60
|
+
controller.startRecording(flightId, opts),
|
|
61
|
+
[controller],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const stop = useCallback(
|
|
65
|
+
(recordingId: string): Promise<RecordingHandle> => controller.stopRecording(recordingId),
|
|
66
|
+
[controller],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const startGroup = useCallback(
|
|
70
|
+
(flightIds: number[], opts?: StartRecordingOptions): string =>
|
|
71
|
+
controller.startGroupedRecording(flightIds, opts).groupId,
|
|
72
|
+
[controller],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const stopGroup = useCallback(
|
|
76
|
+
(
|
|
77
|
+
groupId: string,
|
|
78
|
+
opts?: StopGroupedRecordingOptions,
|
|
79
|
+
): Promise<GroupedRecordingHandle> => controller.stopGroupedRecording(groupId, opts),
|
|
80
|
+
[controller],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const discard = useCallback(
|
|
84
|
+
(recordingId: string): void => controller.discardRecording(recordingId),
|
|
85
|
+
[controller],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const discardGroup = useCallback(
|
|
89
|
+
(groupId: string): void => controller.discardGroupedRecording(groupId),
|
|
90
|
+
[controller],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const isRecording = useCallback(
|
|
94
|
+
(flightId: number): boolean => controller.isRecording(flightId),
|
|
95
|
+
[controller],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
recordings: snapshot.recordings,
|
|
100
|
+
groups: snapshot.groups,
|
|
101
|
+
active: snapshot.active,
|
|
102
|
+
isRecording,
|
|
103
|
+
start,
|
|
104
|
+
stop,
|
|
105
|
+
startGroup,
|
|
106
|
+
stopGroup,
|
|
107
|
+
discard,
|
|
108
|
+
discardGroup,
|
|
109
|
+
};
|
|
110
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,11 @@ export { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
|
27
27
|
export { useKerbcastClock } from "./hooks/useKerbcastClock";
|
|
28
28
|
export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
29
29
|
export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
30
|
+
export { useRecordings } from "./hooks/useRecordings";
|
|
31
|
+
export type { RecordingsStore, ActiveRecordingInfo } from "./hooks/useRecordings";
|
|
32
|
+
|
|
33
|
+
// Shared elapsed-timer helpers (REC badges, RecGroupBar's toolbar)
|
|
34
|
+
export { nowMs, formatElapsed } from "./timing";
|
|
30
35
|
|
|
31
36
|
// Shared standby glyph (used by CameraFeed + downstream dashboards)
|
|
32
37
|
export { StandbyIcon } from "./StandbyIcon";
|
|
@@ -48,6 +53,16 @@ export type {
|
|
|
48
53
|
FeedAction,
|
|
49
54
|
} from "./CameraFeed";
|
|
50
55
|
|
|
56
|
+
// FeedActionBar: the shared action row (tracking, fullscreen, PiP, quality,
|
|
57
|
+
// close, REC) both CameraFeed and KerbalFaceFeed render, with the issue-#6
|
|
58
|
+
// overflow (⋮) engine.
|
|
59
|
+
export { FeedActionBar } from "./FeedActionBar";
|
|
60
|
+
export type { FeedActionBarEntry } from "./FeedActionBar";
|
|
61
|
+
|
|
51
62
|
// KerbalFaceFeed — shared single-kerbal-face primitive
|
|
52
63
|
export { KerbalFaceFeed } from "./KerbalFaceFeed";
|
|
53
64
|
export type { KerbalFaceFeedProps, KerbalFeedState } from "./KerbalFaceFeed";
|
|
65
|
+
|
|
66
|
+
// RecordingsTray: a presentational drawer over the recordings store
|
|
67
|
+
export { RecordingsTray } from "./RecordingsTray";
|
|
68
|
+
export type { RecordingsTrayProps } from "./RecordingsTray";
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
|
|
8
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
9
|
+
|
|
10
|
+
const MENU_GAP = 4; // trigger-to-menu spacing
|
|
11
|
+
const MENU_EDGE = 8; // minimum inset from the viewport edge
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* Geometry of a portaled dropdown: the CSS size caps of the menu (the
|
|
15
|
+
* helper clamps as if the menu fills them) and which edge of the trigger
|
|
16
|
+
* the menu hangs from: "start" lines the menu's left edge up with the
|
|
17
|
+
* trigger's, "end" lines the right edges up.
|
|
18
|
+
*/
|
|
19
|
+
export interface MenuAnchor {
|
|
20
|
+
maxWidth: number;
|
|
21
|
+
maxHeight: number;
|
|
22
|
+
align: "start" | "end";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* Fixed-position style for a portaled menu, anchored to its trigger button.
|
|
27
|
+
* Opens downward by default; flips above the trigger when there is not
|
|
28
|
+
* enough room below but there is above, otherwise clamps to the viewport.
|
|
29
|
+
*/
|
|
30
|
+
export function computeMenuPosition(
|
|
31
|
+
trigger: HTMLElement,
|
|
32
|
+
anchor: MenuAnchor,
|
|
33
|
+
): React.CSSProperties {
|
|
34
|
+
const rect = trigger.getBoundingClientRect();
|
|
35
|
+
const vw = window.innerWidth;
|
|
36
|
+
const vh = window.innerHeight;
|
|
37
|
+
const width = Math.min(anchor.maxWidth, vw - 2 * MENU_EDGE);
|
|
38
|
+
/* Inline offset from the aligned viewport edge, clamped so a full-width
|
|
39
|
+
menu still fits inside the opposite edge. */
|
|
40
|
+
const inset = Math.max(MENU_EDGE, vw - width - MENU_EDGE);
|
|
41
|
+
const inline: React.CSSProperties =
|
|
42
|
+
anchor.align === "start"
|
|
43
|
+
? { left: Math.min(Math.max(rect.left, MENU_EDGE), inset) }
|
|
44
|
+
: { right: Math.min(Math.max(vw - rect.right, MENU_EDGE), inset) };
|
|
45
|
+
const menuH = Math.min(0.4 * vh, anchor.maxHeight);
|
|
46
|
+
const fitsBelow = rect.bottom + MENU_GAP + menuH <= vh - MENU_EDGE;
|
|
47
|
+
const fitsAbove = rect.top - MENU_GAP - menuH >= MENU_EDGE;
|
|
48
|
+
if (!fitsBelow && fitsAbove) {
|
|
49
|
+
return { ...inline, bottom: vh - rect.top + MENU_GAP };
|
|
50
|
+
}
|
|
51
|
+
const top = Math.min(
|
|
52
|
+
rect.bottom + MENU_GAP,
|
|
53
|
+
Math.max(MENU_EDGE, vh - MENU_EDGE - menuH),
|
|
54
|
+
);
|
|
55
|
+
return { ...inline, top };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/*
|
|
59
|
+
* Shared behaviour for a dropdown portaled to document.body: fixed position
|
|
60
|
+
* computed from the trigger's rect (so tile overflow cannot clip it),
|
|
61
|
+
* re-anchored on window resize/scroll while open, Escape to close with focus
|
|
62
|
+
* returned to the trigger, and portal-aware outside-pointer-down dismissal
|
|
63
|
+
* (menuRef points at the portaled menu, so clicks inside it stay "inside").
|
|
64
|
+
* One menu at a time falls out of the dismissal: opening another menu's
|
|
65
|
+
* trigger is an outside press for this one.
|
|
66
|
+
*/
|
|
67
|
+
export function usePortalMenu({ maxWidth, maxHeight, align }: MenuAnchor) {
|
|
68
|
+
const [open, setOpen] = useState(false);
|
|
69
|
+
const [position, setPosition] = useState<React.CSSProperties | null>(null);
|
|
70
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
71
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!open) {
|
|
75
|
+
setPosition(null);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const update = () => {
|
|
79
|
+
const trigger = triggerRef.current;
|
|
80
|
+
if (trigger) {
|
|
81
|
+
setPosition(computeMenuPosition(trigger, { maxWidth, maxHeight, align }));
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
update();
|
|
85
|
+
window.addEventListener("resize", update);
|
|
86
|
+
window.addEventListener("scroll", update, true);
|
|
87
|
+
return () => {
|
|
88
|
+
window.removeEventListener("resize", update);
|
|
89
|
+
window.removeEventListener("scroll", update, true);
|
|
90
|
+
};
|
|
91
|
+
}, [open, maxWidth, maxHeight, align]);
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!open) return;
|
|
95
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
96
|
+
if (e.key === "Escape") {
|
|
97
|
+
e.stopPropagation();
|
|
98
|
+
setOpen(false);
|
|
99
|
+
triggerRef.current?.focus();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
103
|
+
if (
|
|
104
|
+
!menuRef.current?.contains(e.target as Node) &&
|
|
105
|
+
!triggerRef.current?.contains(e.target as Node)
|
|
106
|
+
) {
|
|
107
|
+
setOpen(false);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
document.addEventListener("keydown", onKeyDown);
|
|
111
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
112
|
+
return () => {
|
|
113
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
114
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
115
|
+
};
|
|
116
|
+
}, [open]);
|
|
117
|
+
|
|
118
|
+
const toggle = useCallback(() => setOpen((v) => !v), []);
|
|
119
|
+
/** Close after an item pick, returning focus to the trigger. */
|
|
120
|
+
const close = useCallback(() => {
|
|
121
|
+
setOpen(false);
|
|
122
|
+
triggerRef.current?.focus();
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
return { open, toggle, close, position, menuRef, triggerRef };
|
|
126
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared elapsed-timer helpers, extracted out of CameraFeed.tsx and
|
|
3
|
+
* RecGroupBar.tsx (formerly duplicated verbatim between the two).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { formatElapsed, nowMs } from "./timing";
|
|
8
|
+
|
|
9
|
+
describe("formatElapsed", () => {
|
|
10
|
+
it("formats sub-minute durations as 0:ss", () => {
|
|
11
|
+
expect(formatElapsed(3_000)).toBe("0:03");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("formats minutes and seconds, zero-padding seconds", () => {
|
|
15
|
+
expect(formatElapsed(65_000)).toBe("1:05");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("floors partial seconds", () => {
|
|
19
|
+
expect(formatElapsed(1_999)).toBe("0:01");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("clamps negative durations to 0:00", () => {
|
|
23
|
+
expect(formatElapsed(-500)).toBe("0:00");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("nowMs", () => {
|
|
28
|
+
it("returns a monotonically increasing, non-negative number", () => {
|
|
29
|
+
const a = nowMs();
|
|
30
|
+
const b = nowMs();
|
|
31
|
+
expect(a).toBeGreaterThanOrEqual(0);
|
|
32
|
+
expect(b).toBeGreaterThanOrEqual(a);
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/timing.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
|
|
8
|
+
/** Monotonic clock for elapsed-timer math. Falls back to Date.now under jsdom. */
|
|
9
|
+
export function nowMs(): number {
|
|
10
|
+
return typeof performance !== "undefined" && typeof performance.now === "function"
|
|
11
|
+
? performance.now()
|
|
12
|
+
: Date.now();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Formats a millisecond duration as m:ss (e.g. "REC 1:05", "Recording 3 feeds - 0:42"). */
|
|
16
|
+
export function formatElapsed(ms: number): string {
|
|
17
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
18
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
19
|
+
const seconds = totalSeconds % 60;
|
|
20
|
+
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
21
|
+
}
|