@ksp-gonogo/kerbcast-react 1.11.1 → 1.12.0-rc.2
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/README.md +71 -0
- package/dist/AnchorMarkers.d.ts +128 -0
- package/dist/AnchorMarkers.js +162 -0
- package/dist/CameraFeed.d.ts +45 -4
- package/dist/CameraFeed.js +163 -23
- package/dist/CommandBrackets.d.ts +61 -12
- package/dist/CommandBrackets.js +106 -53
- package/dist/FeedActionBar.d.ts +7 -0
- package/dist/FeedActionBar.js +4 -4
- package/dist/KerbalFaceFeed.d.ts +1 -1
- package/dist/KerbalFaceFeed.js +1 -1
- package/dist/RecordingsTray.d.ts +6 -0
- package/dist/RecordingsTray.js +17 -1
- package/dist/hooks/useCommandDisplayUt.d.ts +16 -3
- package/dist/hooks/useCommandDisplayUt.js +57 -16
- package/dist/hooks/useKerbcastWaitingForSlot.d.ts +5 -0
- package/dist/hooks/useKerbcastWaitingForSlot.js +20 -0
- package/dist/hooks/useTargetBracketsPreference.d.ts +3 -2
- package/dist/hooks/useTargetBracketsPreference.js +18 -5
- package/dist/index.d.ts +4 -1
- package/dist/index.js +5 -1
- package/dist/menuPositioning.d.ts +8 -2
- package/dist/menuPositioning.js +30 -5
- package/dist/sharedAnchors.d.ts +3 -0
- package/dist/sharedAnchors.js +61 -0
- package/dist/videoBox.d.ts +10 -0
- package/dist/videoBox.js +40 -0
- package/package.json +2 -2
- package/src/AnchorMarkers.test.tsx +173 -0
- package/src/AnchorMarkers.tsx +341 -0
- package/src/CameraFeed.test.tsx +687 -6
- package/src/CameraFeed.tsx +240 -27
- package/src/CommandBrackets.test.tsx +204 -4
- package/src/CommandBrackets.tsx +170 -53
- package/src/FeedActionBar.test.ts +22 -0
- package/src/FeedActionBar.tsx +11 -4
- package/src/KerbalFaceFeed.tsx +1 -1
- package/src/RecordingsTray.test.tsx +15 -0
- package/src/RecordingsTray.tsx +25 -1
- package/src/hooks/useCommandDisplayUt.test.tsx +74 -3
- package/src/hooks/useCommandDisplayUt.ts +61 -15
- package/src/hooks/useKerbcastWaitingForSlot.ts +25 -0
- package/src/hooks/useRecordings.sharedState.test.tsx +6 -3
- package/src/hooks/useRecordings.test.tsx +6 -3
- package/src/hooks/useTargetBracketsPreference.ts +15 -5
- package/src/index.ts +23 -1
- package/src/menuPositioning.ts +31 -5
- package/src/sharedAnchors.ts +67 -0
- package/src/videoBox.ts +49 -0
package/src/RecordingsTray.tsx
CHANGED
|
@@ -22,7 +22,11 @@
|
|
|
22
22
|
* camera registry; omit it for a plain "Camera <flightId>" fallback.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
GroupedRecordingHandle,
|
|
27
|
+
RecordingEndReason,
|
|
28
|
+
RecordingHandle,
|
|
29
|
+
} from "@ksp-gonogo/kerbcast";
|
|
26
30
|
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
|
27
31
|
import styled from "styled-components";
|
|
28
32
|
import { useRecordings } from "./hooks/useRecordings";
|
|
@@ -123,6 +127,9 @@ function ClipRow({ rec, label, url, onDiscard }: ClipRowProps) {
|
|
|
123
127
|
<ClipDetail>
|
|
124
128
|
{formatDuration(rec.durationMs)} · {formatBytes(rec.byteSize)}
|
|
125
129
|
</ClipDetail>
|
|
130
|
+
{rec.endedBy && recordingEndNote(rec.endedBy) && (
|
|
131
|
+
<ClipDetail>{recordingEndNote(rec.endedBy)}</ClipDetail>
|
|
132
|
+
)}
|
|
126
133
|
</ClipMeta>
|
|
127
134
|
<ClipActions>
|
|
128
135
|
{url && (
|
|
@@ -243,6 +250,23 @@ function formatUt(ut: number): string {
|
|
|
243
250
|
return `T+${Math.round(ut)}s`;
|
|
244
251
|
}
|
|
245
252
|
|
|
253
|
+
/**
|
|
254
|
+
* A short line saying why a recording stopped on its own, or null for one a
|
|
255
|
+
* person stopped or that ran its set length.
|
|
256
|
+
*/
|
|
257
|
+
export function recordingEndNote(endedBy: RecordingEndReason): string | null {
|
|
258
|
+
switch (endedBy) {
|
|
259
|
+
case "camera-gone":
|
|
260
|
+
return "Stopped: the camera went away";
|
|
261
|
+
case "feed-closed":
|
|
262
|
+
return "Stopped: its feed was closed";
|
|
263
|
+
case "disconnected":
|
|
264
|
+
return "Stopped: the connection was lost";
|
|
265
|
+
default:
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
246
270
|
function formatDuration(ms: number): string {
|
|
247
271
|
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
|
248
272
|
const hours = Math.floor(totalSeconds / 3600);
|
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
* which it is, and a host override beats both.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { KerbcastClient, type CommandTrackEvent } from "@ksp-gonogo/kerbcast";
|
|
7
|
+
import { KerbcastClient, type CommandTrackEvent, type FrameCommandState } from "@ksp-gonogo/kerbcast";
|
|
8
8
|
import { MockSidecar } from "@ksp-gonogo/kerbcast/testing";
|
|
9
9
|
import { act, cleanup, render } from "@testing-library/react";
|
|
10
10
|
import { afterEach, describe, expect, it } from "vitest";
|
|
11
11
|
import { KerbcastProvider } from "../context";
|
|
12
|
-
import { crossesEvent, useCommandDisplayUt, type CommandDisplayUt } from "./useCommandDisplayUt";
|
|
12
|
+
import { commandShownKey, crossesEvent, useCommandDisplayUt, type CommandDisplayUt } from "./useCommandDisplayUt";
|
|
13
13
|
import { useDisplayedFrameUt } from "./useDisplayedFrameUt";
|
|
14
14
|
|
|
15
15
|
async function connected() {
|
|
@@ -54,10 +54,11 @@ const event = (ut: number): CommandTrackEvent =>
|
|
|
54
54
|
let last: CommandDisplayUt | null | undefined;
|
|
55
55
|
function CommandProbe(props: {
|
|
56
56
|
override?: number;
|
|
57
|
+
overrideCommand?: FrameCommandState;
|
|
57
58
|
events?: CommandTrackEvent[];
|
|
58
59
|
video: HTMLVideoElement | null;
|
|
59
60
|
}) {
|
|
60
|
-
last = useCommandDisplayUt(true, props.override, props.events, 7, props.video);
|
|
61
|
+
last = useCommandDisplayUt(true, props.override, props.events, 7, props.video, props.overrideCommand);
|
|
61
62
|
return null;
|
|
62
63
|
}
|
|
63
64
|
|
|
@@ -106,6 +107,76 @@ describe("useCommandDisplayUt", () => {
|
|
|
106
107
|
expect(last?.ut).toBe(999.95);
|
|
107
108
|
});
|
|
108
109
|
|
|
110
|
+
it("carries the displayed frame's command state and re-renders when what it shows changes", async () => {
|
|
111
|
+
const { client, sidecar } = await connected();
|
|
112
|
+
const { video, present } = fakeVideo();
|
|
113
|
+
render(
|
|
114
|
+
<KerbcastProvider client={client}>
|
|
115
|
+
<CommandProbe video={video} />
|
|
116
|
+
</KerbcastProvider>,
|
|
117
|
+
);
|
|
118
|
+
const command = (rangeM: number, occluded = false) => ({
|
|
119
|
+
hasFix: true,
|
|
120
|
+
rangeM,
|
|
121
|
+
frameHeightPx: 720,
|
|
122
|
+
framingFits: true,
|
|
123
|
+
occluded,
|
|
124
|
+
occluder: occluded ? "Mun" : "",
|
|
125
|
+
sunlit: true,
|
|
126
|
+
subjectPersistentId: 42,
|
|
127
|
+
});
|
|
128
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 1, captureUt: 999.8, captureEpoch: 0, command: command(1500) });
|
|
129
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 2, captureUt: 999.85, captureEpoch: 0, command: command(1500.4) });
|
|
130
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 3, captureUt: 999.9, captureEpoch: 0, command: command(1500.4, true) });
|
|
131
|
+
present(1);
|
|
132
|
+
expect(last).toMatchObject({ ut: 999.8, source: "exact", command: { rangeM: 1500 } });
|
|
133
|
+
/* A range change below what the readout shows keeps the frame shown. */
|
|
134
|
+
present(2);
|
|
135
|
+
expect(last?.ut).toBe(999.8);
|
|
136
|
+
present(3);
|
|
137
|
+
expect(last).toMatchObject({ ut: 999.9, command: { occluded: true, occluder: "Mun" } });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("keeps the command state of a frame it can only estimate, which lags the picture", async () => {
|
|
141
|
+
const { client, sidecar } = await connected();
|
|
142
|
+
const { video, present } = fakeVideo();
|
|
143
|
+
render(
|
|
144
|
+
<KerbcastProvider client={client}>
|
|
145
|
+
<CommandProbe video={video} />
|
|
146
|
+
</KerbcastProvider>,
|
|
147
|
+
);
|
|
148
|
+
const command: FrameCommandState = {
|
|
149
|
+
hasFix: true, rangeM: 900, frameHeightPx: 720, framingFits: true, occluded: false, occluder: "", sunlit: true, subjectPersistentId: 42,
|
|
150
|
+
};
|
|
151
|
+
sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 1, captureUt: 999.8, captureEpoch: 0, command });
|
|
152
|
+
present(1);
|
|
153
|
+
/* A frame the browser cannot identify falls back to the last exact one: estimated, but its state still applies. */
|
|
154
|
+
present(55555);
|
|
155
|
+
expect(last).toMatchObject({ ut: 999.8, source: "estimated", command: { rangeM: 900 } });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("takes a delayed host's own frame state with its override", async () => {
|
|
159
|
+
const { client } = await connected();
|
|
160
|
+
const command: FrameCommandState = {
|
|
161
|
+
hasFix: true, rangeM: 1200, frameHeightPx: 720, framingFits: true, occluded: true, occluder: "Mun", sunlit: true, subjectPersistentId: 42,
|
|
162
|
+
};
|
|
163
|
+
render(
|
|
164
|
+
<KerbcastProvider client={client}>
|
|
165
|
+
<CommandProbe video={null} override={950} overrideCommand={command} />
|
|
166
|
+
</KerbcastProvider>,
|
|
167
|
+
);
|
|
168
|
+
expect(last).toEqual({ ut: 950, source: "override", command });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("matches the command state's shown key to what a viewer sees", () => {
|
|
172
|
+
const base = { hasFix: true, frameHeightPx: 720, framingFits: true, occluded: false, occluder: "", sunlit: true, subjectPersistentId: 1 };
|
|
173
|
+
expect(commandShownKey({ ...base, rangeM: 1500 })).toBe(commandShownKey({ ...base, rangeM: 1501 }));
|
|
174
|
+
expect(commandShownKey({ ...base, rangeM: 1500 })).not.toBe(commandShownKey({ ...base, rangeM: 1600 }));
|
|
175
|
+
expect(commandShownKey({ ...base, refusal: "stale" })).not.toBe(commandShownKey(base));
|
|
176
|
+
expect(commandShownKey({ ...base, waitingForTarget: true })).not.toBe(commandShownKey(base));
|
|
177
|
+
expect(commandShownKey(undefined)).toBe("");
|
|
178
|
+
});
|
|
179
|
+
|
|
109
180
|
it("prefers a host override over the displayed frame", async () => {
|
|
110
181
|
const { client, sidecar } = await connected();
|
|
111
182
|
const { video, present } = fakeVideo();
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type DisplayedFrameUt,
|
|
5
5
|
type FrameUtSource,
|
|
6
6
|
type CommandTrackEvent,
|
|
7
|
+
type FrameCommandState,
|
|
7
8
|
} from "@ksp-gonogo/kerbcast";
|
|
8
9
|
import { useKerbcastClient } from "../context";
|
|
9
10
|
import { nowMs } from "../timing";
|
|
@@ -40,6 +41,37 @@ export function crossesEvent(
|
|
|
40
41
|
export interface CommandDisplayUt {
|
|
41
42
|
ut: number;
|
|
42
43
|
source: FrameUtSource | "override";
|
|
44
|
+
/** The command state that rode with the displayed frame (1.12 and later), or the host's own with an override. */
|
|
45
|
+
command?: FrameCommandState;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What of a frame's command state a viewer can see change: the discrete
|
|
50
|
+
* fields whole, ranges to 3 significant figures, pixel sizes to half a
|
|
51
|
+
* pixel. Frames that agree on it draw the same, so they skip a re-render.
|
|
52
|
+
*/
|
|
53
|
+
export function commandShownKey(c: FrameCommandState | undefined): string {
|
|
54
|
+
if (!c) return "";
|
|
55
|
+
const sig = (v: number | undefined) => (v == null ? "" : Number(v.toPrecision(3)));
|
|
56
|
+
const half = (v: number | undefined) => (v == null ? "" : Math.round(v * 2));
|
|
57
|
+
return JSON.stringify([
|
|
58
|
+
c.hasFix,
|
|
59
|
+
sig(c.rangeM),
|
|
60
|
+
half(c.bracketHalfPx),
|
|
61
|
+
c.frameHeightPx,
|
|
62
|
+
c.framingFits,
|
|
63
|
+
half(c.trueOffsetPx),
|
|
64
|
+
c.occluded,
|
|
65
|
+
c.occluder,
|
|
66
|
+
c.sunlit,
|
|
67
|
+
c.blank ?? null,
|
|
68
|
+
c.authority?.owner ?? null,
|
|
69
|
+
c.subjectPersistentId,
|
|
70
|
+
c.refusal ?? null,
|
|
71
|
+
c.refusalCode ?? null,
|
|
72
|
+
c.refusalText ?? null,
|
|
73
|
+
c.waitingForTarget ?? false,
|
|
74
|
+
]);
|
|
43
75
|
}
|
|
44
76
|
|
|
45
77
|
/**
|
|
@@ -50,7 +82,12 @@ export interface CommandDisplayUt {
|
|
|
50
82
|
* interpolated to now (`"estimated"`). Re-evaluated twice a second while
|
|
51
83
|
* `enabled` so readout ages keep counting, and again the moment the
|
|
52
84
|
* displayed UT reaches the next of `events`, so a state change lands on its
|
|
53
|
-
* frame rather than on the next tick.
|
|
85
|
+
* frame rather than on the next tick. The displayed frame's own command
|
|
86
|
+
* state comes back as `command`, re-rendering when what it shows changes:
|
|
87
|
+
* an exact frame's, an estimated one's (which lags the picture, so is never
|
|
88
|
+
* early; its own UT is used too, so events and lists stay on that frame),
|
|
89
|
+
* or with an override the host's own `overrideCommand`. `null` when
|
|
90
|
+
* nothing is known.
|
|
54
91
|
*/
|
|
55
92
|
export function useCommandDisplayUt(
|
|
56
93
|
enabled: boolean,
|
|
@@ -58,6 +95,7 @@ export function useCommandDisplayUt(
|
|
|
58
95
|
events?: readonly CommandTrackEvent[],
|
|
59
96
|
flightId?: number | null,
|
|
60
97
|
video?: HTMLVideoElement | null,
|
|
98
|
+
overrideCommand?: FrameCommandState | null,
|
|
61
99
|
): CommandDisplayUt | null {
|
|
62
100
|
const client = useKerbcastClient();
|
|
63
101
|
const sample = useRef<{ captureUt: number | null; warpRate: number; at: number }>({
|
|
@@ -86,23 +124,27 @@ export function useCommandDisplayUt(
|
|
|
86
124
|
return () => clearInterval(interval);
|
|
87
125
|
}, [enabled]);
|
|
88
126
|
|
|
89
|
-
/*
|
|
90
|
-
source,
|
|
127
|
+
/* Frames re-render only when they change what is drawn: a switch of
|
|
128
|
+
source, a step across an event, or a change the command state shows.
|
|
129
|
+
Ages catch up on the tick. An estimated frame is kept only for the
|
|
130
|
+
command state it carries. */
|
|
91
131
|
const overriding = override !== undefined;
|
|
92
132
|
useEffect(() => {
|
|
93
133
|
frame.current = null;
|
|
94
134
|
if (!enabled || overriding || flightId == null || !video) return;
|
|
95
135
|
return client.watchDisplayedFrames(flightId, video, (f) => {
|
|
96
|
-
frame.current = f.source === "exact" ? f : null;
|
|
136
|
+
frame.current = f.source === "exact" || f.command ? f : null;
|
|
97
137
|
const prev = shown.current;
|
|
98
138
|
if (!frame.current) {
|
|
99
|
-
if (prev?.source === "exact") tick();
|
|
139
|
+
if (prev?.source === "exact" || prev?.command) tick();
|
|
100
140
|
return;
|
|
101
141
|
}
|
|
102
142
|
if (
|
|
103
143
|
prev === null ||
|
|
104
|
-
prev.source !== "exact" ||
|
|
105
|
-
|
|
144
|
+
(f.source === "exact" && prev.source !== "exact") ||
|
|
145
|
+
(f.source !== "exact" && prev.source === "exact") ||
|
|
146
|
+
crossesEvent(eventsRef.current, prev.ut, f.ut) ||
|
|
147
|
+
commandShownKey(prev.command) !== commandShownKey(f.command)
|
|
106
148
|
) {
|
|
107
149
|
tick();
|
|
108
150
|
}
|
|
@@ -110,17 +152,21 @@ export function useCommandDisplayUt(
|
|
|
110
152
|
}, [client, enabled, overriding, flightId, video]);
|
|
111
153
|
|
|
112
154
|
const { captureUt, warpRate, at } = sample.current;
|
|
113
|
-
const
|
|
155
|
+
const seen = frame.current;
|
|
156
|
+
const withCommand = (d: CommandDisplayUt, c: FrameCommandState | null | undefined): CommandDisplayUt =>
|
|
157
|
+
c ? { ...d, command: c } : d;
|
|
114
158
|
const displayUt: CommandDisplayUt | null = overriding
|
|
115
|
-
? { ut: override, source: "override" }
|
|
116
|
-
: exact
|
|
117
|
-
? { ut:
|
|
118
|
-
:
|
|
119
|
-
?
|
|
120
|
-
:
|
|
159
|
+
? withCommand({ ut: override, source: "override" }, overrideCommand)
|
|
160
|
+
: seen?.source === "exact"
|
|
161
|
+
? withCommand({ ut: seen.ut, source: "exact" }, seen.command)
|
|
162
|
+
: seen
|
|
163
|
+
? withCommand({ ut: seen.ut, source: "estimated" }, seen.command)
|
|
164
|
+
: captureUt === null
|
|
165
|
+
? null
|
|
166
|
+
: { ut: extrapolateCaptureUt(captureUt, warpRate, at, nowMs()), source: "estimated" };
|
|
121
167
|
shown.current = displayUt;
|
|
122
168
|
const untilEdge =
|
|
123
|
-
enabled && displayUt?.source === "estimated"
|
|
169
|
+
enabled && displayUt?.source === "estimated" && !seen
|
|
124
170
|
? msUntilNextEvent(events, displayUt.ut, warpRate)
|
|
125
171
|
: null;
|
|
126
172
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useKerbcastClient } from "../context";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether this client wants camera `flightId` but has no video slot to carry
|
|
6
|
+
* it (see `KerbcastCameraHandle.waitingForSlot`). False for a null id.
|
|
7
|
+
*/
|
|
8
|
+
export function useKerbcastWaitingForSlot(flightId: number | null): boolean {
|
|
9
|
+
const client = useKerbcastClient();
|
|
10
|
+
const [waiting, setWaiting] = useState(() =>
|
|
11
|
+
flightId === null ? false : client.camera(flightId).waitingForSlot,
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (flightId === null) {
|
|
16
|
+
setWaiting(false);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const cam = client.camera(flightId);
|
|
20
|
+
setWaiting(cam.waitingForSlot);
|
|
21
|
+
return cam.on("waiting", setWaiting);
|
|
22
|
+
}, [client, flightId]);
|
|
23
|
+
|
|
24
|
+
return waiting;
|
|
25
|
+
}
|
|
@@ -31,13 +31,16 @@ function liveStream(): MediaStream {
|
|
|
31
31
|
/** Fake RecordingClient with a settable clock; enough for the controller. */
|
|
32
32
|
class FakeRecordingClient implements RecordingClient {
|
|
33
33
|
captureUt: number | null = 100;
|
|
34
|
-
camera(_flightId: number): {
|
|
35
|
-
|
|
34
|
+
camera(_flightId: number): {
|
|
35
|
+
readonly mediaStream: MediaStream | null;
|
|
36
|
+
on(): () => void;
|
|
37
|
+
} {
|
|
38
|
+
return { mediaStream: liveStream(), on: () => () => {} };
|
|
36
39
|
}
|
|
37
40
|
get clock(): { readonly captureUt: number | null } {
|
|
38
41
|
return { captureUt: this.captureUt };
|
|
39
42
|
}
|
|
40
|
-
on(
|
|
43
|
+
on(): () => void {
|
|
41
44
|
return () => {};
|
|
42
45
|
}
|
|
43
46
|
}
|
|
@@ -33,13 +33,16 @@ function liveStream(): MediaStream {
|
|
|
33
33
|
/** Fake RecordingClient with a settable clock; enough for the controller. */
|
|
34
34
|
class FakeRecordingClient implements RecordingClient {
|
|
35
35
|
captureUt: number | null = 100;
|
|
36
|
-
camera(_flightId: number): {
|
|
37
|
-
|
|
36
|
+
camera(_flightId: number): {
|
|
37
|
+
readonly mediaStream: MediaStream | null;
|
|
38
|
+
on(): () => void;
|
|
39
|
+
} {
|
|
40
|
+
return { mediaStream: liveStream(), on: () => () => {} };
|
|
38
41
|
}
|
|
39
42
|
get clock(): { readonly captureUt: number | null } {
|
|
40
43
|
return { captureUt: this.captureUt };
|
|
41
44
|
}
|
|
42
|
-
on(
|
|
45
|
+
on(): () => void {
|
|
43
46
|
return () => {};
|
|
44
47
|
}
|
|
45
48
|
}
|
|
@@ -2,9 +2,18 @@ import { useCallback, useMemo, useState } from "react";
|
|
|
2
2
|
|
|
3
3
|
const STORAGE_PREFIX = "kerbcast:target-brackets:";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/* A preference saved under `legacyKey` moves to `key` the first time it is read. */
|
|
6
|
+
function readPreference(key: string, legacyKey: string | null): boolean {
|
|
6
7
|
try {
|
|
7
|
-
|
|
8
|
+
const storage = globalThis.localStorage;
|
|
9
|
+
if (!storage) return true;
|
|
10
|
+
const value = storage.getItem(STORAGE_PREFIX + key);
|
|
11
|
+
if (value !== null || legacyKey === null || legacyKey === key) return value !== "0";
|
|
12
|
+
const legacy = storage.getItem(STORAGE_PREFIX + legacyKey);
|
|
13
|
+
if (legacy === null) return true;
|
|
14
|
+
storage.setItem(STORAGE_PREFIX + key, legacy);
|
|
15
|
+
storage.removeItem(STORAGE_PREFIX + legacyKey);
|
|
16
|
+
return legacy !== "0";
|
|
8
17
|
} catch {
|
|
9
18
|
return true;
|
|
10
19
|
}
|
|
@@ -22,11 +31,12 @@ function writePreference(key: string, on: boolean): void {
|
|
|
22
31
|
/**
|
|
23
32
|
* Whether target brackets are shown for one command camera, remembered per
|
|
24
33
|
* camera in this browser. Defaults on. `key` is null for a feed with no
|
|
25
|
-
* command camera, which reads as on and ignores toggles.
|
|
34
|
+
* command camera, which reads as on and ignores toggles. A preference saved
|
|
35
|
+
* under `legacyKey` (the key an older version used) is moved to `key`.
|
|
26
36
|
*/
|
|
27
|
-
export function useTargetBracketsPreference(key: string | null): [boolean, () => void] {
|
|
37
|
+
export function useTargetBracketsPreference(key: string | null, legacyKey: string | null = null): [boolean, () => void] {
|
|
28
38
|
const [overrides, setOverrides] = useState<Record<string, boolean>>({});
|
|
29
|
-
const stored = useMemo(() => (key === null ? true : readPreference(key)), [key]);
|
|
39
|
+
const stored = useMemo(() => (key === null ? true : readPreference(key, legacyKey)), [key, legacyKey]);
|
|
30
40
|
const on = key !== null && key in overrides ? overrides[key]! : stored;
|
|
31
41
|
const toggle = useCallback(() => {
|
|
32
42
|
if (key === null) return;
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ export { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
|
27
27
|
export { useKerbcastClock } from "./hooks/useKerbcastClock";
|
|
28
28
|
export { useDisplayedFrameUt } from "./hooks/useDisplayedFrameUt";
|
|
29
29
|
export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
30
|
+
export { useKerbcastWaitingForSlot } from "./hooks/useKerbcastWaitingForSlot";
|
|
30
31
|
export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
31
32
|
export { useRecordings } from "./hooks/useRecordings";
|
|
32
33
|
export type { RecordingsStore, ActiveRecordingInfo } from "./hooks/useRecordings";
|
|
@@ -68,6 +69,27 @@ export type {
|
|
|
68
69
|
ContentRect,
|
|
69
70
|
} from "./CommandBrackets";
|
|
70
71
|
|
|
72
|
+
// Anchored markers: CameraFeed's `anchors` and `anchorOverlay`, and the
|
|
73
|
+
// pieces for drawing a frame's anchors over a video of your own
|
|
74
|
+
export {
|
|
75
|
+
AnchorMarkers,
|
|
76
|
+
ANCHOR_MARKER_COLOURS,
|
|
77
|
+
anchorMarkerGeometry,
|
|
78
|
+
useDisplayedFrame,
|
|
79
|
+
} from "./AnchorMarkers";
|
|
80
|
+
export type {
|
|
81
|
+
AnchorContext,
|
|
82
|
+
AnchorCorner,
|
|
83
|
+
AnchorCornerOverride,
|
|
84
|
+
AnchorCornerOverrides,
|
|
85
|
+
AnchorCornerStyle,
|
|
86
|
+
AnchorMarkerGeometry,
|
|
87
|
+
AnchorMarkerShape,
|
|
88
|
+
AnchorMarkersProps,
|
|
89
|
+
AnchorOverlay,
|
|
90
|
+
PlacedAnchor,
|
|
91
|
+
} from "./AnchorMarkers";
|
|
92
|
+
|
|
71
93
|
// FeedActionBar: the shared action row (tracking, fullscreen, PiP, quality,
|
|
72
94
|
// close, REC) both CameraFeed and KerbalFaceFeed render, with the issue-#6
|
|
73
95
|
// overflow (⋮) engine.
|
|
@@ -79,5 +101,5 @@ export { KerbalFaceFeed } from "./KerbalFaceFeed";
|
|
|
79
101
|
export type { KerbalFaceFeedProps, KerbalFeedState } from "./KerbalFaceFeed";
|
|
80
102
|
|
|
81
103
|
// RecordingsTray: a presentational drawer over the recordings store
|
|
82
|
-
export { RecordingsTray } from "./RecordingsTray";
|
|
104
|
+
export { RecordingsTray, recordingEndNote } from "./RecordingsTray";
|
|
83
105
|
export type { RecordingsTrayProps } from "./RecordingsTray";
|
package/src/menuPositioning.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared geometry + behaviour for a
|
|
3
|
-
* Used by CameraFeed's camera / quality / tracking menus and by
|
|
2
|
+
* Shared geometry + behaviour for a portaled dropdown (document.body, or the
|
|
3
|
+
* fullscreen element while one is fullscreen). Used by CameraFeed's camera / quality / tracking menus and by
|
|
4
4
|
* FeedActionBar's overflow menu, so the anchoring, dismissal, and Escape
|
|
5
5
|
* handling live in exactly one place.
|
|
6
6
|
*/
|
|
@@ -55,8 +55,19 @@ export function computeMenuPosition(
|
|
|
55
55
|
return { ...inline, top };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Where a dropdown portals to. Only the fullscreen element and its
|
|
60
|
+
* descendants render while something is fullscreen, so a menu portaled to
|
|
61
|
+
* document.body then would open invisibly behind it.
|
|
62
|
+
*/
|
|
63
|
+
export function menuPortalRoot(): Element {
|
|
64
|
+
const d = document as Document & { webkitFullscreenElement?: Element | null };
|
|
65
|
+
return d.fullscreenElement ?? d.webkitFullscreenElement ?? document.body;
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
/*
|
|
59
|
-
* Shared behaviour for a dropdown portaled to document.body
|
|
69
|
+
* Shared behaviour for a dropdown portaled to document.body (or the
|
|
70
|
+
* fullscreen element, see menuPortalRoot): fixed position
|
|
60
71
|
* computed from the trigger's rect (so tile overflow cannot clip it),
|
|
61
72
|
* re-anchored on window resize/scroll while open, Escape to close with focus
|
|
62
73
|
* returned to the trigger, and portal-aware outside-pointer-down dismissal
|
|
@@ -92,13 +103,24 @@ export function usePortalMenu({ maxWidth, maxHeight, align }: MenuAnchor) {
|
|
|
92
103
|
|
|
93
104
|
useEffect(() => {
|
|
94
105
|
if (!open) return;
|
|
106
|
+
/* Capture phase and preventDefault so document-level Escape handlers
|
|
107
|
+
registered earlier (CameraFeed's exit-fullscreen key) see the press
|
|
108
|
+
as already consumed by the menu. */
|
|
95
109
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
96
110
|
if (e.key === "Escape") {
|
|
111
|
+
e.preventDefault();
|
|
97
112
|
e.stopPropagation();
|
|
98
113
|
setOpen(false);
|
|
99
114
|
triggerRef.current?.focus();
|
|
100
115
|
}
|
|
101
116
|
};
|
|
117
|
+
/* The portal root changes with fullscreen and no resize may fire (kiosk,
|
|
118
|
+
F11), so a menu left open would remount elsewhere with stale
|
|
119
|
+
coordinates. */
|
|
120
|
+
const onFullscreenChange = () => {
|
|
121
|
+
setOpen(false);
|
|
122
|
+
triggerRef.current?.focus();
|
|
123
|
+
};
|
|
102
124
|
const onPointerDown = (e: PointerEvent) => {
|
|
103
125
|
if (
|
|
104
126
|
!menuRef.current?.contains(e.target as Node) &&
|
|
@@ -107,11 +129,15 @@ export function usePortalMenu({ maxWidth, maxHeight, align }: MenuAnchor) {
|
|
|
107
129
|
setOpen(false);
|
|
108
130
|
}
|
|
109
131
|
};
|
|
110
|
-
document.addEventListener("keydown", onKeyDown);
|
|
132
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
111
133
|
document.addEventListener("pointerdown", onPointerDown);
|
|
134
|
+
document.addEventListener("fullscreenchange", onFullscreenChange);
|
|
135
|
+
document.addEventListener("webkitfullscreenchange", onFullscreenChange);
|
|
112
136
|
return () => {
|
|
113
|
-
document.removeEventListener("keydown", onKeyDown);
|
|
137
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
114
138
|
document.removeEventListener("pointerdown", onPointerDown);
|
|
139
|
+
document.removeEventListener("fullscreenchange", onFullscreenChange);
|
|
140
|
+
document.removeEventListener("webkitfullscreenchange", onFullscreenChange);
|
|
115
141
|
};
|
|
116
142
|
}, [open]);
|
|
117
143
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Anchor, KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A client holds one anchor set per camera, so feeds showing the same
|
|
5
|
+
* camera each claim their anchors here and the camera gets them all: the
|
|
6
|
+
* first claim of an id wins, and the set stops at the client's cap.
|
|
7
|
+
*/
|
|
8
|
+
const claims = new WeakMap<KerbcastClient, Map<number, Map<symbol, readonly Anchor[]>>>();
|
|
9
|
+
|
|
10
|
+
/** Add `anchors` to `flightId`'s set until the returned release is called. */
|
|
11
|
+
export function claimAnchors(
|
|
12
|
+
client: KerbcastClient,
|
|
13
|
+
flightId: number,
|
|
14
|
+
anchors: readonly Anchor[],
|
|
15
|
+
): () => void {
|
|
16
|
+
let byFlight = claims.get(client);
|
|
17
|
+
if (!byFlight) {
|
|
18
|
+
byFlight = new Map();
|
|
19
|
+
claims.set(client, byFlight);
|
|
20
|
+
}
|
|
21
|
+
let feeds = byFlight.get(flightId);
|
|
22
|
+
if (!feeds) {
|
|
23
|
+
feeds = new Map();
|
|
24
|
+
byFlight.set(flightId, feeds);
|
|
25
|
+
}
|
|
26
|
+
const token = Symbol("anchors");
|
|
27
|
+
feeds.set(token, anchors);
|
|
28
|
+
publish(client, flightId, feeds);
|
|
29
|
+
return () => {
|
|
30
|
+
if (!feeds.delete(token)) return;
|
|
31
|
+
if (feeds.size === 0) byFlight.delete(flightId);
|
|
32
|
+
publish(client, flightId, feeds);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function publish(client: KerbcastClient, flightId: number, feeds: Map<symbol, readonly Anchor[]>): void {
|
|
37
|
+
const ids = new Map<string, string>();
|
|
38
|
+
const merged: Anchor[] = [];
|
|
39
|
+
let dropped = 0;
|
|
40
|
+
for (const list of feeds.values()) {
|
|
41
|
+
for (const a of list) {
|
|
42
|
+
const seen = ids.get(a.id);
|
|
43
|
+
if (seen !== undefined) {
|
|
44
|
+
if (seen !== JSON.stringify(a)) {
|
|
45
|
+
console.warn(`[kerbcast] two feeds on camera ${flightId} set different anchors with id ${a.id}; the first is kept`);
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (merged.length >= client.maxAnchorsPerCamera) {
|
|
50
|
+
dropped++;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
ids.set(a.id, JSON.stringify(a));
|
|
54
|
+
merged.push(a);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (dropped > 0) {
|
|
58
|
+
console.warn(`[kerbcast] ${dropped} anchors over the ${client.maxAnchorsPerCamera} per camera left out`);
|
|
59
|
+
}
|
|
60
|
+
if (merged.length === 0) {
|
|
61
|
+
void client.setAnchors(flightId, null).catch(() => {});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
client.setAnchors(flightId, merged).catch((err: unknown) => {
|
|
65
|
+
console.warn("[kerbcast] anchors refused", err);
|
|
66
|
+
});
|
|
67
|
+
}
|
package/src/videoBox.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface VideoBox {
|
|
4
|
+
boxW: number;
|
|
5
|
+
boxH: number;
|
|
6
|
+
mediaW: number;
|
|
7
|
+
mediaH: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Tracks the rendered box and intrinsic size of a video element. */
|
|
11
|
+
export function useVideoBox(video: HTMLVideoElement | null): VideoBox {
|
|
12
|
+
const [box, setBox] = useState<VideoBox>({ boxW: 0, boxH: 0, mediaW: 0, mediaH: 0 });
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!video) return;
|
|
15
|
+
const measure = () => {
|
|
16
|
+
const next = {
|
|
17
|
+
boxW: video.clientWidth,
|
|
18
|
+
boxH: video.clientHeight,
|
|
19
|
+
mediaW: video.videoWidth,
|
|
20
|
+
mediaH: video.videoHeight,
|
|
21
|
+
};
|
|
22
|
+
setBox((prev) =>
|
|
23
|
+
prev.boxW === next.boxW &&
|
|
24
|
+
prev.boxH === next.boxH &&
|
|
25
|
+
prev.mediaW === next.mediaW &&
|
|
26
|
+
prev.mediaH === next.mediaH
|
|
27
|
+
? prev
|
|
28
|
+
: next,
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
measure();
|
|
32
|
+
const observer = new ResizeObserver(measure);
|
|
33
|
+
observer.observe(video);
|
|
34
|
+
video.addEventListener("loadedmetadata", measure);
|
|
35
|
+
video.addEventListener("resize", measure);
|
|
36
|
+
return () => {
|
|
37
|
+
observer.disconnect();
|
|
38
|
+
video.removeEventListener("loadedmetadata", measure);
|
|
39
|
+
video.removeEventListener("resize", measure);
|
|
40
|
+
};
|
|
41
|
+
}, [video]);
|
|
42
|
+
return box;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The element's CSS `object-fit`, as the two fits a feed uses. */
|
|
46
|
+
export function videoFit(video: HTMLVideoElement | null): "contain" | "cover" {
|
|
47
|
+
if (!video || typeof getComputedStyle !== "function") return "contain";
|
|
48
|
+
return getComputedStyle(video).objectFit === "cover" ? "cover" : "contain";
|
|
49
|
+
}
|