@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.
Files changed (42) hide show
  1. package/README.md +59 -0
  2. package/dist/AnchorMarkers.d.ts +128 -0
  3. package/dist/AnchorMarkers.js +162 -0
  4. package/dist/CameraFeed.d.ts +45 -4
  5. package/dist/CameraFeed.js +78 -10
  6. package/dist/CommandBrackets.d.ts +61 -12
  7. package/dist/CommandBrackets.js +106 -53
  8. package/dist/KerbalFaceFeed.d.ts +1 -1
  9. package/dist/KerbalFaceFeed.js +1 -1
  10. package/dist/RecordingsTray.d.ts +6 -0
  11. package/dist/RecordingsTray.js +17 -1
  12. package/dist/hooks/useCommandDisplayUt.d.ts +16 -3
  13. package/dist/hooks/useCommandDisplayUt.js +57 -16
  14. package/dist/hooks/useKerbcastWaitingForSlot.d.ts +5 -0
  15. package/dist/hooks/useKerbcastWaitingForSlot.js +20 -0
  16. package/dist/hooks/useTargetBracketsPreference.d.ts +3 -2
  17. package/dist/hooks/useTargetBracketsPreference.js +18 -5
  18. package/dist/index.d.ts +4 -1
  19. package/dist/index.js +5 -1
  20. package/dist/sharedAnchors.d.ts +3 -0
  21. package/dist/sharedAnchors.js +61 -0
  22. package/dist/videoBox.d.ts +10 -0
  23. package/dist/videoBox.js +40 -0
  24. package/package.json +2 -2
  25. package/src/AnchorMarkers.test.tsx +173 -0
  26. package/src/AnchorMarkers.tsx +341 -0
  27. package/src/CameraFeed.test.tsx +223 -6
  28. package/src/CameraFeed.tsx +145 -14
  29. package/src/CommandBrackets.test.tsx +204 -4
  30. package/src/CommandBrackets.tsx +170 -53
  31. package/src/KerbalFaceFeed.tsx +1 -1
  32. package/src/RecordingsTray.test.tsx +15 -0
  33. package/src/RecordingsTray.tsx +25 -1
  34. package/src/hooks/useCommandDisplayUt.test.tsx +74 -3
  35. package/src/hooks/useCommandDisplayUt.ts +61 -15
  36. package/src/hooks/useKerbcastWaitingForSlot.ts +25 -0
  37. package/src/hooks/useRecordings.sharedState.test.tsx +6 -3
  38. package/src/hooks/useRecordings.test.tsx +6 -3
  39. package/src/hooks/useTargetBracketsPreference.ts +15 -5
  40. package/src/index.ts +23 -1
  41. package/src/sharedAnchors.ts +67 -0
  42. 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): { readonly mediaStream: MediaStream | null } {
35
- return { mediaStream: liveStream() };
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(_event: "settings-change", _handler: (data: unknown) => void): () => void {
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): { readonly mediaStream: MediaStream | null } {
37
- return { mediaStream: liveStream() };
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(_event: "settings-change", _handler: (data: unknown) => void): () => void {
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
- function readPreference(key: string): boolean {
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
- return globalThis.localStorage?.getItem(STORAGE_PREFIX + key) !== "0";
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
+ }
@@ -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
+ }