@ksp-gonogo/kerbcast-react 1.11.0 → 1.12.0-rc.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.
- package/README.md +59 -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 +78 -10
- package/dist/CommandBrackets.d.ts +61 -12
- package/dist/CommandBrackets.js +106 -53
- 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/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 +223 -6
- package/src/CameraFeed.tsx +145 -14
- package/src/CommandBrackets.test.tsx +204 -4
- package/src/CommandBrackets.tsx +170 -53
- 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/sharedAnchors.ts +67 -0
- package/src/videoBox.ts +49 -0
|
@@ -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";
|
|
@@ -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
|
+
}
|