@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.
Files changed (49) hide show
  1. package/README.md +71 -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 +163 -23
  6. package/dist/CommandBrackets.d.ts +61 -12
  7. package/dist/CommandBrackets.js +106 -53
  8. package/dist/FeedActionBar.d.ts +7 -0
  9. package/dist/FeedActionBar.js +4 -4
  10. package/dist/KerbalFaceFeed.d.ts +1 -1
  11. package/dist/KerbalFaceFeed.js +1 -1
  12. package/dist/RecordingsTray.d.ts +6 -0
  13. package/dist/RecordingsTray.js +17 -1
  14. package/dist/hooks/useCommandDisplayUt.d.ts +16 -3
  15. package/dist/hooks/useCommandDisplayUt.js +57 -16
  16. package/dist/hooks/useKerbcastWaitingForSlot.d.ts +5 -0
  17. package/dist/hooks/useKerbcastWaitingForSlot.js +20 -0
  18. package/dist/hooks/useTargetBracketsPreference.d.ts +3 -2
  19. package/dist/hooks/useTargetBracketsPreference.js +18 -5
  20. package/dist/index.d.ts +4 -1
  21. package/dist/index.js +5 -1
  22. package/dist/menuPositioning.d.ts +8 -2
  23. package/dist/menuPositioning.js +30 -5
  24. package/dist/sharedAnchors.d.ts +3 -0
  25. package/dist/sharedAnchors.js +61 -0
  26. package/dist/videoBox.d.ts +10 -0
  27. package/dist/videoBox.js +40 -0
  28. package/package.json +2 -2
  29. package/src/AnchorMarkers.test.tsx +173 -0
  30. package/src/AnchorMarkers.tsx +341 -0
  31. package/src/CameraFeed.test.tsx +687 -6
  32. package/src/CameraFeed.tsx +240 -27
  33. package/src/CommandBrackets.test.tsx +204 -4
  34. package/src/CommandBrackets.tsx +170 -53
  35. package/src/FeedActionBar.test.ts +22 -0
  36. package/src/FeedActionBar.tsx +11 -4
  37. package/src/KerbalFaceFeed.tsx +1 -1
  38. package/src/RecordingsTray.test.tsx +15 -0
  39. package/src/RecordingsTray.tsx +25 -1
  40. package/src/hooks/useCommandDisplayUt.test.tsx +74 -3
  41. package/src/hooks/useCommandDisplayUt.ts +61 -15
  42. package/src/hooks/useKerbcastWaitingForSlot.ts +25 -0
  43. package/src/hooks/useRecordings.sharedState.test.tsx +6 -3
  44. package/src/hooks/useRecordings.test.tsx +6 -3
  45. package/src/hooks/useTargetBracketsPreference.ts +15 -5
  46. package/src/index.ts +23 -1
  47. package/src/menuPositioning.ts +31 -5
  48. package/src/sharedAnchors.ts +67 -0
  49. package/src/videoBox.ts +49 -0
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import {
26
+ AnchorKind,
26
27
  CameraKind,
27
28
  CommandTargetEligibility,
28
29
  KerbcastClient,
@@ -30,10 +31,12 @@ import {
30
31
  TrackMode,
31
32
  } from "@ksp-gonogo/kerbcast";
32
33
  import type {
34
+ Anchor,
33
35
  CameraLifecycle,
34
36
  ClientMessage,
35
37
  CommandCentreInfo,
36
38
  CommandTrackState,
39
+ DisplayedFrameUt,
37
40
  } from "@ksp-gonogo/kerbcast";
38
41
  import { type MockCameraInit, MockSidecar } from "@ksp-gonogo/kerbcast/testing";
39
42
  import {
@@ -995,6 +998,107 @@ describe("CameraFeed - stall presentation (showStatic)", () => {
995
998
  }
996
999
  });
997
1000
 
1001
+ it("data-kc-signal follows a feed into and out of a stall without remounting its video", async () => {
1002
+ const env = installStallEnv();
1003
+ try {
1004
+ const { client, sidecar } = await buildConnectedSource();
1005
+ const { container } = renderFeed(client, { flightId: 42 });
1006
+ const signal = () => container.querySelector("[data-kc-signal]")?.getAttribute("data-kc-signal");
1007
+ expect(signal()).toBe("none");
1008
+
1009
+ await act(async () => {
1010
+ sidecar.deliverTrack(sidecar.slotMidFor(42) as string, {} as MediaStreamTrack);
1011
+ });
1012
+ env.setNow(1000);
1013
+ await act(async () => {
1014
+ env.fireFrame();
1015
+ env.drawOnce();
1016
+ });
1017
+ expect(signal()).toBe("live");
1018
+ const video = container.querySelector("video");
1019
+
1020
+ env.setNow(1601);
1021
+ await act(async () => {
1022
+ env.drawOnce();
1023
+ });
1024
+ expect(signal()).toBe("stalled");
1025
+ expect(container.querySelector("video")).toBe(video);
1026
+
1027
+ await act(async () => {
1028
+ env.fireFrame();
1029
+ env.drawOnce();
1030
+ });
1031
+ expect(signal()).toBe("live");
1032
+ expect(container.querySelector("video")).toBe(video);
1033
+ } finally {
1034
+ env.restore();
1035
+ }
1036
+ });
1037
+
1038
+ it("data-kc-signal reads stalled for a source that never delivers a frame", async () => {
1039
+ const env = installStallEnv();
1040
+ try {
1041
+ const { client, sidecar } = await buildConnectedSource();
1042
+ const { container } = renderFeed(client, { flightId: 42 });
1043
+ const signal = () => container.querySelector("[data-kc-signal]")?.getAttribute("data-kc-signal");
1044
+
1045
+ env.setNow(1000);
1046
+ await act(async () => {
1047
+ sidecar.deliverTrack(sidecar.slotMidFor(42) as string, {} as MediaStreamTrack);
1048
+ env.drawOnce();
1049
+ });
1050
+ env.setNow(1601);
1051
+ await act(async () => {
1052
+ env.drawOnce();
1053
+ });
1054
+ expect(signal()).toBe("stalled");
1055
+ } finally {
1056
+ env.restore();
1057
+ }
1058
+ });
1059
+
1060
+ it("data-kc-signal reads stalled after a camera switch until the new camera's first frame", async () => {
1061
+ const env = installStallEnv();
1062
+ try {
1063
+ const { client, sidecar } = await buildConnectedSource([
1064
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
1065
+ makeCamera({ flightId: 43, cameraName: "Port Cam" }),
1066
+ ]);
1067
+ const view = renderFeed(client, { flightId: 42 });
1068
+ const signal = () => view.container.querySelector("[data-kc-signal]")?.getAttribute("data-kc-signal");
1069
+ await act(async () => {
1070
+ sidecar.deliverTrack(sidecar.slotMidFor(42) as string, {} as MediaStreamTrack);
1071
+ });
1072
+ env.setNow(1000);
1073
+ await act(async () => {
1074
+ env.fireFrame();
1075
+ env.drawOnce();
1076
+ });
1077
+ expect(signal()).toBe("live");
1078
+
1079
+ /* The freed slot passes straight to the new camera, so there is a
1080
+ source at once but no frame of it yet. */
1081
+ view.rerender(
1082
+ <KerbcastProvider client={client}>
1083
+ <CameraFeed flightId={43} />
1084
+ </KerbcastProvider>,
1085
+ );
1086
+ env.setNow(1601);
1087
+ await act(async () => {
1088
+ env.drawOnce();
1089
+ });
1090
+ expect(signal()).toBe("stalled");
1091
+
1092
+ await act(async () => {
1093
+ env.fireFrame();
1094
+ env.drawOnce();
1095
+ });
1096
+ expect(signal()).toBe("live");
1097
+ } finally {
1098
+ env.restore();
1099
+ }
1100
+ });
1101
+
998
1102
  it("SIGNAL LOST supersedes the stale badge when the camera is destroyed", async () => {
999
1103
  const env = installStallEnv();
1000
1104
  try {
@@ -2747,9 +2851,54 @@ describe("CameraFeed - recording locks source and close", () => {
2747
2851
  // Command camera target brackets
2748
2852
  // ---------------------------------------------------------------------------
2749
2853
 
2854
+
2855
+ describe("CameraFeed: no video slot", () => {
2856
+ const NOTICE = /Not streaming: too many feeds are open/;
2857
+ const ids = [1, 2, 3, 4, 5];
2858
+ const feeds = (client: KerbcastClient, shown: number[], label?: string | null) => (
2859
+ <KerbcastProvider client={client}>
2860
+ {shown.map((id) => (
2861
+ <CameraFeed
2862
+ key={id}
2863
+ flightId={id}
2864
+ {...(label !== undefined ? { noSlotLabel: label } : {})}
2865
+ />
2866
+ ))}
2867
+ </KerbcastProvider>
2868
+ );
2869
+
2870
+ it("says so on the feed that found every slot taken, until another feed closes", async () => {
2871
+ const { client } = await buildConnectedSource(
2872
+ ids.map((id) => makeCamera({ flightId: id, cameraName: `Cam ${id}` })),
2873
+ );
2874
+ const view = render(feeds(client, ids));
2875
+ await act(async () => {});
2876
+ expect(screen.getAllByText(NOTICE)).toHaveLength(1);
2877
+ expect(client.camera(5).waitingForSlot).toBe(true);
2878
+
2879
+ view.rerender(feeds(client, [2, 3, 4, 5]));
2880
+ await act(async () => {});
2881
+ expect(screen.queryByText(NOTICE)).toBeNull();
2882
+ expect(client.camera(5).waitingForSlot).toBe(false);
2883
+ });
2884
+
2885
+ it("takes a host's own words, or none", async () => {
2886
+ const { client } = await buildConnectedSource(
2887
+ ids.map((id) => makeCamera({ flightId: id, cameraName: `Cam ${id}` })),
2888
+ );
2889
+ const view = render(feeds(client, ids, "No room"));
2890
+ await act(async () => {});
2891
+ expect(screen.getByText("No room")).toBeTruthy();
2892
+ view.rerender(feeds(client, ids, null));
2893
+ expect(screen.queryByText("No room")).toBeNull();
2894
+ expect(screen.queryByText(NOTICE)).toBeNull();
2895
+ });
2896
+ });
2897
+
2750
2898
  describe("CameraFeed - command camera target brackets", () => {
2751
2899
  const STATION: CommandCentreInfo = {
2752
2900
  stationId: "ground:KSC",
2901
+ siteKey: "KSC",
2753
2902
  nodeName: "KSC",
2754
2903
  displayName: "Kerbal Space Center",
2755
2904
  body: "Kerbin",
@@ -2809,7 +2958,7 @@ describe("CameraFeed - command camera target brackets", () => {
2809
2958
  const first = renderFeed(client, { flightId: 301 });
2810
2959
  fireEvent.click(screen.getByRole("button", { name: "Target brackets" }));
2811
2960
  expect(screen.queryByText("IN VIEW · 11,508 km")).toBeNull();
2812
- expect(localStorage.getItem("kerbcast:target-brackets:ground:KSC")).toBe("0");
2961
+ expect(localStorage.getItem("kerbcast:target-brackets:KSC")).toBe("0");
2813
2962
  first.unmount();
2814
2963
 
2815
2964
  renderFeed(client, { flightId: 301 });
@@ -2819,22 +2968,87 @@ describe("CameraFeed - command camera target brackets", () => {
2819
2968
  ).toBe("false");
2820
2969
  });
2821
2970
 
2822
- it("still labels a blanked feed with the brackets toggled off", async () => {
2971
+ it("carries a brackets-off choice saved under the 1.11 key over to the site key", async () => {
2823
2972
  localStorage.setItem("kerbcast:target-brackets:ground:KSC", "0");
2973
+ const { client } = await buildConnectedSource([commandCamera()]);
2974
+ renderFeed(client, { flightId: 301 });
2975
+ expect(
2976
+ screen.getByRole("button", { name: "Target brackets" }).getAttribute("aria-pressed"),
2977
+ ).toBe("false");
2978
+ expect(localStorage.getItem("kerbcast:target-brackets:KSC")).toBe("0");
2979
+ expect(localStorage.getItem("kerbcast:target-brackets:ground:KSC")).toBeNull();
2980
+ });
2981
+
2982
+ it("still labels a blanked feed with the brackets toggled off", async () => {
2983
+ localStorage.setItem("kerbcast:target-brackets:KSC", "0");
2824
2984
  const { client, sidecar } = await buildConnectedSource([commandCamera()]);
2825
2985
  renderFeed(client, { flightId: 301 });
2826
2986
  expect(screen.queryByRole("status", { name: "Feed blanked" })).toBeNull();
2827
2987
  await act(async () => {
2828
- sidecar.updateCamera(301, { commandTrack: commandTrack({ blank: "Waiting for gonogo" }) });
2988
+ sidecar.updateCamera(301, { commandTrack: commandTrack({ blank: "Waiting for mission control" }) });
2829
2989
  });
2830
2990
  expect(screen.getByRole("status", { name: "Feed blanked" }).textContent).toBe(
2831
- "Waiting for gonogo",
2991
+ "Waiting for mission control",
2832
2992
  );
2833
2993
  });
2834
2994
 
2995
+ describe("waiting for a target", () => {
2996
+ const waitingFrame = (waitingForTarget: boolean): DisplayedFrameUt => ({
2997
+ flightId: 301,
2998
+ ut: 1000,
2999
+ epoch: 0,
3000
+ source: "exact",
3001
+ rtpTimestamp: 1,
3002
+ presentedAt: 0,
3003
+ command: {
3004
+ hasFix: false,
3005
+ frameHeightPx: 576,
3006
+ framingFits: true,
3007
+ occluded: false,
3008
+ occluder: "",
3009
+ sunlit: false,
3010
+ subjectPersistentId: 0,
3011
+ waitingForTarget,
3012
+ },
3013
+ });
3014
+ const feed = (client: KerbcastClient, frame: DisplayedFrameUt, label?: string | null) => (
3015
+ <KerbcastProvider client={client}>
3016
+ <CameraFeed
3017
+ flightId={301}
3018
+ displayFrame={frame}
3019
+ {...(label !== undefined ? { waitingForTargetLabel: label } : {})}
3020
+ />
3021
+ </KerbcastProvider>
3022
+ );
3023
+
3024
+ it("says so while the frame does, and stops once the target arrives", async () => {
3025
+ const { client } = await buildConnectedSource([commandCamera()]);
3026
+ const view = render(feed(client, waitingFrame(true)));
3027
+ expect(screen.getByText("Waiting for target")).toBeTruthy();
3028
+ view.rerender(feed(client, waitingFrame(false)));
3029
+ expect(screen.queryByText("Waiting for target")).toBeNull();
3030
+ });
3031
+
3032
+ it("takes a host's own words, or none", async () => {
3033
+ const { client } = await buildConnectedSource([commandCamera()]);
3034
+ const view = render(feed(client, waitingFrame(true), "Acquiring"));
3035
+ expect(screen.getByText("Acquiring")).toBeTruthy();
3036
+ view.rerender(feed(client, waitingFrame(true), null));
3037
+ expect(screen.queryByText("Acquiring")).toBeNull();
3038
+ expect(screen.queryByText("Waiting for target")).toBeNull();
3039
+ });
3040
+
3041
+ it("gives way to a blank", async () => {
3042
+ const { client } = await buildConnectedSource([commandCamera()]);
3043
+ const frame = waitingFrame(true);
3044
+ render(feed(client, { ...frame, command: { ...frame.command!, blank: "Held" } }));
3045
+ expect(screen.getByRole("status", { name: "Feed blanked" })).toBeTruthy();
3046
+ expect(screen.queryByText("Waiting for target")).toBeNull();
3047
+ });
3048
+ });
3049
+
2835
3050
  it("shows the transition in force at the displayed UT", async () => {
2836
3051
  const track = commandTrack({
2837
- authority: { owner: "gonogo" },
2838
3052
  events: [
2839
3053
  { ut: 990, occluded: true, occluder: "Mun", sunlit: true },
2840
3054
  { ut: 999, occluded: false, occluder: "", sunlit: true },
@@ -2873,6 +3087,46 @@ describe("CameraFeed - command camera target brackets", () => {
2873
3087
  expect(screen.getByText(/ · T\+1060$/)).toBeTruthy();
2874
3088
  });
2875
3089
 
3090
+ it("gives the brackets the subject's anchor from the displayed frame's stamp", async () => {
3091
+ const { client, sidecar } = await buildConnectedSource([commandCamera()]);
3092
+ const subjects: (string | undefined)[] = [];
3093
+ renderFeed(client, {
3094
+ flightId: 301,
3095
+ targetBracketOverlay: {
3096
+ render: (ctx) => {
3097
+ subjects.push(ctx.subject ? `${ctx.subject.owner}/${ctx.subject.id}` : undefined);
3098
+ return null;
3099
+ },
3100
+ },
3101
+ });
3102
+ await act(async () => {
3103
+ sidecar.fireFrameStamp({
3104
+ flightId: 301,
3105
+ rtpTimestamp: 1,
3106
+ captureUt: 1000,
3107
+ captureEpoch: 0,
3108
+ width: 1024,
3109
+ height: 576,
3110
+ anchors: [
3111
+ {
3112
+ id: "subject",
3113
+ owner: "kerbcast",
3114
+ x: 100,
3115
+ y: 100,
3116
+ inFrame: true,
3117
+ behind: false,
3118
+ distanceM: 11_507_905,
3119
+ apparentRadiusPx: 4,
3120
+ angleOffAxis: 3,
3121
+ occluded: false,
3122
+ },
3123
+ ],
3124
+ });
3125
+ });
3126
+ /* Without frame callbacks (jsdom) a stamp is taken as on screen once it is old enough. */
3127
+ await waitFor(() => expect(subjects).toContain("kerbcast/subject"), { timeout: 2000 });
3128
+ });
3129
+
2876
3130
  describe("target picker", () => {
2877
3131
  const TARGETS: CommandCentreInfo = {
2878
3132
  ...STATION,
@@ -2993,7 +3247,7 @@ describe("CameraFeed - command camera target brackets", () => {
2993
3247
  kind: CameraKind.Command,
2994
3248
  cameraName: "KSC tracking",
2995
3249
  commandCentre: TARGETS,
2996
- commandTrack: commandTrack({ authority: { owner: "gonogo" } }),
3250
+ commandTrack: commandTrack({ authority: { owner: "mission-control" } }),
2997
3251
  }),
2998
3252
  ]);
2999
3253
  const lockedView = renderFeed(locked.client, { flightId: 301 });
@@ -3013,3 +3267,430 @@ describe("CameraFeed - command camera target brackets", () => {
3013
3267
  expect(screen.queryByRole("button", { name: "Target brackets" })).toBeNull();
3014
3268
  });
3015
3269
  });
3270
+
3271
+ describe("CameraFeed - anchors", () => {
3272
+ const port = { id: "port", kind: AnchorKind.Part, vessel: 11, part: 22, orientation: true };
3273
+
3274
+ function Feed({ client, flightId, anchors }: { client: KerbcastClient; flightId: number; anchors?: Anchor[] }) {
3275
+ return (
3276
+ <KerbcastProvider client={client}>
3277
+ <CameraFeed flightId={flightId} anchors={anchors} />
3278
+ </KerbcastProvider>
3279
+ );
3280
+ }
3281
+
3282
+ it("sets its anchors on the displayed camera and clears them when it moves or unmounts", async () => {
3283
+ const { client, sidecar } = await buildConnectedSource([
3284
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
3285
+ makeCamera({ flightId: 43, cameraName: "Port Cam" }),
3286
+ ]);
3287
+ const view = render(<Feed client={client} flightId={42} anchors={[port]} />);
3288
+ await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port]));
3289
+
3290
+ /* A new array with the same anchors is no change. */
3291
+ const sent = () => sidecar.commands.filter((c) => c.type === "set-anchors").length;
3292
+ const before = sent();
3293
+ view.rerender(<Feed client={client} flightId={42} anchors={[{ ...port }]} />);
3294
+ expect(sent()).toBe(before);
3295
+
3296
+ view.rerender(<Feed client={client} flightId={43} anchors={[port]} />);
3297
+ await waitFor(() => expect(sidecar.anchorsFor(43)).toEqual([port]));
3298
+ expect(sidecar.anchorsFor(42)).toEqual([]);
3299
+
3300
+ view.unmount();
3301
+ await waitFor(() => expect(sidecar.anchorsFor(43)).toEqual([]));
3302
+ });
3303
+
3304
+ it("gives two feeds on one camera both their anchors, until each unmounts", async () => {
3305
+ const { client, sidecar } = await buildConnectedSource([
3306
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
3307
+ ]);
3308
+ const dock = { id: "dock", kind: AnchorKind.Vessel, vessel: 12 };
3309
+ const first = render(<Feed client={client} flightId={42} anchors={[port]} />);
3310
+ const second = render(<Feed client={client} flightId={42} anchors={[port, dock]} />);
3311
+ await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port, dock]));
3312
+
3313
+ first.unmount();
3314
+ await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port, dock]));
3315
+ second.rerender(<Feed client={client} flightId={42} anchors={[dock]} />);
3316
+ await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([dock]));
3317
+ second.unmount();
3318
+ await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([]));
3319
+ });
3320
+
3321
+ it("warns when two feeds on one camera use one anchor id for different things", async () => {
3322
+ const { client, sidecar } = await buildConnectedSource([makeCamera({ flightId: 42 })]);
3323
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
3324
+ const other = { ...port, part: 23 };
3325
+ render(<Feed client={client} flightId={42} anchors={[port]} />);
3326
+ render(<Feed client={client} flightId={42} anchors={[port]} />);
3327
+ expect(warn).not.toHaveBeenCalled();
3328
+ render(<Feed client={client} flightId={42} anchors={[other]} />);
3329
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("id port"));
3330
+ await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port]));
3331
+ warn.mockRestore();
3332
+ });
3333
+ });
3334
+
3335
+ describe("CameraFeed - fullscreen exit without a mouse", () => {
3336
+ type FsDoc = Document & {
3337
+ fullscreenEnabled?: boolean;
3338
+ fullscreenElement?: Element | null;
3339
+ exitFullscreen?: () => Promise<void>;
3340
+ };
3341
+ const fsDoc = document as FsDoc;
3342
+ let fsElement: Element | null = null;
3343
+ const exitSpy = vi.fn(async () => {
3344
+ fsElement = null;
3345
+ document.dispatchEvent(new Event("fullscreenchange"));
3346
+ });
3347
+
3348
+ beforeEach(() => {
3349
+ fsElement = null;
3350
+ exitSpy.mockClear();
3351
+ Object.defineProperty(fsDoc, "fullscreenEnabled", { value: true, configurable: true });
3352
+ Object.defineProperty(fsDoc, "fullscreenElement", {
3353
+ get: () => fsElement,
3354
+ configurable: true,
3355
+ });
3356
+ Object.defineProperty(fsDoc, "exitFullscreen", { value: exitSpy, configurable: true });
3357
+ Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
3358
+ value: async function (this: HTMLElement) {
3359
+ fsElement = this;
3360
+ document.dispatchEvent(new Event("fullscreenchange"));
3361
+ },
3362
+ configurable: true,
3363
+ });
3364
+ });
3365
+
3366
+ afterEach(() => {
3367
+ delete (fsDoc as { fullscreenEnabled?: boolean }).fullscreenEnabled;
3368
+ delete (fsDoc as { fullscreenElement?: Element | null }).fullscreenElement;
3369
+ delete (fsDoc as { exitFullscreen?: unknown }).exitFullscreen;
3370
+ delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
3371
+ delete (document as { pictureInPictureEnabled?: boolean }).pictureInPictureEnabled;
3372
+ });
3373
+
3374
+ async function enterFullscreen() {
3375
+ const pipDoc = document as Document & { pictureInPictureEnabled?: boolean };
3376
+ Object.defineProperty(pipDoc, "pictureInPictureEnabled", { value: true, configurable: true });
3377
+ const { client } = await buildConnectedSource([
3378
+ makeCamera({
3379
+ flightId: 42,
3380
+ cameraName: "Launchpad Cam",
3381
+ supportsPan: true,
3382
+ supportsZoom: true,
3383
+ panPitchMin: -90,
3384
+ panPitchMax: 90,
3385
+ }),
3386
+ ]);
3387
+ /* Crowded enough (REC + tracking + PiP + fullscreen) that fullscreen
3388
+ starts out in the ⋮ overflow. */
3389
+ const utils = renderFeed(client, {
3390
+ flightId: 42,
3391
+ enableRecording: true,
3392
+ enableTracking: true,
3393
+ enableFullscreen: true,
3394
+ enablePictureInPicture: true,
3395
+ });
3396
+ fireEvent.click(utils.getByLabelText("More actions"));
3397
+ await act(async () => {
3398
+ fireEvent.click(utils.getByLabelText("Enter fullscreen"));
3399
+ });
3400
+ return utils;
3401
+ }
3402
+
3403
+ it("keeps the exit inline and marked as the always-visible control", async () => {
3404
+ const { getByLabelText } = await enterFullscreen();
3405
+ const exit = getByLabelText("Exit fullscreen");
3406
+ expect(exit.hasAttribute("data-kc-fullscreen-exit")).toBe(true);
3407
+ expect(exit.closest("[role=menu]")).toBeNull();
3408
+ expect(exit.closest("[data-fullscreen]")).toBe(fsElement);
3409
+ expect(exit.tabIndex).toBe(0);
3410
+ /* The ⋮ menu hands focus back to its trigger, which reveals the whole
3411
+ bar by focus-within; at rest only the exit shows. */
3412
+ (document.activeElement as HTMLElement | null)?.blur();
3413
+ expect(getComputedStyle(exit).opacity).not.toBe("0");
3414
+ expect(getComputedStyle(exit).pointerEvents).not.toBe("none");
3415
+ expect(getComputedStyle(getByLabelText("Auto-track")).opacity).toBe("0");
3416
+
3417
+ await act(async () => {
3418
+ fireEvent.click(exit);
3419
+ });
3420
+ expect(exitSpy).toHaveBeenCalledTimes(1);
3421
+ expect(document.querySelector("[data-kc-fullscreen-exit]")).toBeNull();
3422
+ expect(document.querySelector("[data-fullscreen]")).toBeNull();
3423
+ });
3424
+
3425
+ it("entering fullscreen adds the exit without moving the rest of the bar", async () => {
3426
+ const barLabels = () => {
3427
+ const row = screen.getByLabelText("More actions").parentElement as HTMLElement;
3428
+ return Array.from(row.children).map((c) => c.getAttribute("aria-label") ?? "steps");
3429
+ };
3430
+ const pipDoc = document as Document & { pictureInPictureEnabled?: boolean };
3431
+ Object.defineProperty(pipDoc, "pictureInPictureEnabled", { value: true, configurable: true });
3432
+ const { client } = await buildConnectedSource([
3433
+ makeCamera({
3434
+ flightId: 42,
3435
+ cameraName: "Launchpad Cam",
3436
+ supportsPan: true,
3437
+ supportsZoom: true,
3438
+ panPitchMin: -90,
3439
+ panPitchMax: 90,
3440
+ }),
3441
+ ]);
3442
+ const { getByLabelText } = renderFeed(client, {
3443
+ flightId: 42,
3444
+ enableRecording: true,
3445
+ enableTracking: true,
3446
+ enableFullscreen: true,
3447
+ enablePictureInPicture: true,
3448
+ trailingActions: [{ id: "x", label: "Close", icon: null, onClick: () => {} }],
3449
+ });
3450
+ const before = barLabels();
3451
+ expect(before).toContain("More actions");
3452
+ expect(before).not.toContain("Picture in picture");
3453
+
3454
+ fireEvent.click(getByLabelText("More actions"));
3455
+ await act(async () => {
3456
+ fireEvent.click(getByLabelText("Enter fullscreen"));
3457
+ });
3458
+ const after = barLabels();
3459
+ expect(after.filter((l) => l !== "Exit fullscreen")).toEqual(before);
3460
+ expect(after.indexOf("Exit fullscreen")).toBe(after.indexOf("More actions") - 1);
3461
+ });
3462
+
3463
+ it("leaves an Escape another handler already consumed alone", async () => {
3464
+ await enterFullscreen();
3465
+ const consumed = new KeyboardEvent("keydown", { key: "Escape", cancelable: true });
3466
+ consumed.preventDefault();
3467
+ await act(async () => {
3468
+ document.dispatchEvent(consumed);
3469
+ });
3470
+ expect(exitSpy).not.toHaveBeenCalled();
3471
+ });
3472
+
3473
+ it("a tap on the video reveals the rest of the bar", async () => {
3474
+ const { getByLabelText } = await enterFullscreen();
3475
+ (document.activeElement as HTMLElement | null)?.blur();
3476
+ expect(getComputedStyle(getByLabelText("Auto-track")).opacity).toBe("0");
3477
+ const video = document.querySelector("video") as HTMLVideoElement;
3478
+ fireEvent.click(video);
3479
+ expect(getComputedStyle(getByLabelText("Auto-track")).opacity).not.toBe("0");
3480
+ });
3481
+
3482
+ it("Escape leaves fullscreen", async () => {
3483
+ const { queryByLabelText } = await enterFullscreen();
3484
+ await act(async () => {
3485
+ fireEvent.keyDown(document, { key: "Escape" });
3486
+ });
3487
+ expect(exitSpy).toHaveBeenCalledTimes(1);
3488
+ expect(queryByLabelText("Exit fullscreen")).toBeNull();
3489
+ });
3490
+ });
3491
+
3492
+ describe("CameraFeed - video source on camera switch", () => {
3493
+ const TWO_CAMERAS = [
3494
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
3495
+ makeCamera({ flightId: 43, cameraName: "Nose Cam" }),
3496
+ ];
3497
+
3498
+ it("clears the previous frame when the new camera has no stream yet", async () => {
3499
+ const { client } = await buildConnectedSource(TWO_CAMERAS);
3500
+ const sentinel = {} as MediaStream;
3501
+ const useCustom: CameraStreamHook = (id) => (id === 42 ? sentinel : null);
3502
+ const view = (flightId: number) => (
3503
+ <KerbcastProvider client={client}>
3504
+ <CameraFeed flightId={flightId} useStream={useCustom} />
3505
+ </KerbcastProvider>
3506
+ );
3507
+ const { rerender } = render(view(42));
3508
+ const video = document.querySelector("video") as HTMLVideoElement;
3509
+ expect(video.srcObject).toBe(sentinel);
3510
+
3511
+ rerender(view(43));
3512
+ expect(document.querySelector("video")).toBe(video);
3513
+ expect(video.srcObject).toBeNull();
3514
+ });
3515
+
3516
+ it("keeps the element bound when a rebind wraps the same track in a new stream", async () => {
3517
+ const { client } = await buildConnectedSource(TWO_CAMERAS);
3518
+ const track = { id: "track-1", kind: "video" } as MediaStreamTrack;
3519
+ const wrap = () =>
3520
+ ({ getVideoTracks: () => [track] }) as unknown as MediaStream;
3521
+ const first = wrap();
3522
+ const second = wrap();
3523
+ const view = (stream: MediaStream) => {
3524
+ const useFixed: CameraStreamHook = () => stream;
3525
+ return (
3526
+ <KerbcastProvider client={client}>
3527
+ <CameraFeed flightId={42} useStream={useFixed} />
3528
+ </KerbcastProvider>
3529
+ );
3530
+ };
3531
+ const { rerender } = render(view(first));
3532
+ const video = document.querySelector("video") as HTMLVideoElement;
3533
+ expect(video.srcObject).toBe(first);
3534
+
3535
+ rerender(view(second));
3536
+ expect(video.srcObject).toBe(first);
3537
+
3538
+ const other = { getVideoTracks: () => [{ id: "track-2" }] } as unknown as MediaStream;
3539
+ rerender(view(other));
3540
+ expect(video.srcObject).toBe(other);
3541
+ });
3542
+
3543
+ it("never reassigns a live stream on a re-render", async () => {
3544
+ const { client } = await buildConnectedSource(TWO_CAMERAS);
3545
+ const sentinel = {} as MediaStream;
3546
+ const useCustom: CameraStreamHook = () => sentinel;
3547
+ const view = (debug: boolean) => (
3548
+ <KerbcastProvider client={client}>
3549
+ <CameraFeed flightId={42} useStream={useCustom} showDebugInfo={debug} />
3550
+ </KerbcastProvider>
3551
+ );
3552
+ const { rerender } = render(view(false));
3553
+ const video = document.querySelector("video") as HTMLVideoElement;
3554
+ expect(video.srcObject).toBe(sentinel);
3555
+
3556
+ let current: MediaProvider | null = sentinel;
3557
+ const sets: (MediaProvider | null)[] = [];
3558
+ Object.defineProperty(video, "srcObject", {
3559
+ get: () => current,
3560
+ set: (v: MediaProvider | null) => {
3561
+ sets.push(v);
3562
+ current = v;
3563
+ },
3564
+ configurable: true,
3565
+ });
3566
+ rerender(view(true));
3567
+ rerender(view(false));
3568
+ expect(sets).toEqual([]);
3569
+ expect(video.srcObject).toBe(sentinel);
3570
+ });
3571
+ });
3572
+
3573
+ describe("CameraFeed - menus in fullscreen", () => {
3574
+ type FsDoc = Document & {
3575
+ fullscreenEnabled?: boolean;
3576
+ fullscreenElement?: Element | null;
3577
+ pictureInPictureEnabled?: boolean;
3578
+ };
3579
+ const fsDoc = document as FsDoc;
3580
+ let fsElement: Element | null = null;
3581
+
3582
+ beforeEach(() => {
3583
+ fsElement = null;
3584
+ Object.defineProperty(fsDoc, "fullscreenEnabled", { value: true, configurable: true });
3585
+ Object.defineProperty(fsDoc, "pictureInPictureEnabled", { value: true, configurable: true });
3586
+ Object.defineProperty(fsDoc, "fullscreenElement", {
3587
+ get: () => fsElement,
3588
+ configurable: true,
3589
+ });
3590
+ Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
3591
+ value: async function (this: HTMLElement) {
3592
+ fsElement = this;
3593
+ document.dispatchEvent(new Event("fullscreenchange"));
3594
+ },
3595
+ configurable: true,
3596
+ });
3597
+ });
3598
+
3599
+ afterEach(() => {
3600
+ delete (fsDoc as { fullscreenEnabled?: boolean }).fullscreenEnabled;
3601
+ delete (fsDoc as { pictureInPictureEnabled?: boolean }).pictureInPictureEnabled;
3602
+ delete (fsDoc as { fullscreenElement?: Element | null }).fullscreenElement;
3603
+ delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
3604
+ });
3605
+
3606
+ it("opens the more actions menu inside the fullscreen feed on the first tap", async () => {
3607
+ const { client } = await buildConnectedSource();
3608
+ const { getByLabelText, getByRole } = renderFeed(client, {
3609
+ flightId: 42,
3610
+ enableFullscreen: true,
3611
+ enablePictureInPicture: true,
3612
+ actions: [
3613
+ { id: "a", label: "Spotlight", icon: null, onClick: () => {} },
3614
+ { id: "b", label: "Snapshot", icon: null, onClick: () => {} },
3615
+ ],
3616
+ trailingActions: [{ id: "x", label: "Close", icon: null, onClick: () => {} }],
3617
+ });
3618
+ /* Crowded enough that ⋮ stays even once fullscreen pins its exit inline. */
3619
+ fireEvent.click(getByLabelText("More actions"));
3620
+ await act(async () => {
3621
+ fireEvent.click(getByLabelText("Enter fullscreen"));
3622
+ });
3623
+ expect(fsElement).not.toBeNull();
3624
+
3625
+ const trigger = getByLabelText("More actions");
3626
+ fireEvent.pointerDown(trigger);
3627
+ fireEvent.click(trigger);
3628
+ const menu = getByRole("menu", { name: "More actions" });
3629
+ expect(fsElement?.contains(menu)).toBe(true);
3630
+
3631
+ fireEvent.pointerDown(menu);
3632
+ expect(getByRole("menu", { name: "More actions" })).toBe(menu);
3633
+ });
3634
+
3635
+ async function openMenuInFullscreen() {
3636
+ const { client } = await buildConnectedSource();
3637
+ const utils = renderFeed(client, {
3638
+ flightId: 42,
3639
+ enableFullscreen: true,
3640
+ enablePictureInPicture: true,
3641
+ actions: [
3642
+ { id: "a", label: "Spotlight", icon: null, onClick: () => {} },
3643
+ { id: "b", label: "Snapshot", icon: null, onClick: () => {} },
3644
+ ],
3645
+ });
3646
+ fireEvent.click(utils.getByLabelText("More actions"));
3647
+ await act(async () => {
3648
+ fireEvent.click(utils.getByLabelText("Enter fullscreen"));
3649
+ });
3650
+ fireEvent.click(utils.getByLabelText("More actions"));
3651
+ expect(utils.getByRole("menu", { name: "More actions" })).toBeTruthy();
3652
+ return utils;
3653
+ }
3654
+
3655
+ it("Escape with a menu open closes the menu and stays fullscreen", async () => {
3656
+ const exitSpy = vi.fn();
3657
+ Object.defineProperty(fsDoc, "exitFullscreen", { value: exitSpy, configurable: true });
3658
+ try {
3659
+ const { queryByRole, getByLabelText, getByRole } = await openMenuInFullscreen();
3660
+ await act(async () => {
3661
+ fireEvent.keyDown(getByRole("button", { name: "More actions" }), { key: "Escape" });
3662
+ });
3663
+ expect(queryByRole("menu", { name: "More actions" })).toBeNull();
3664
+ expect(exitSpy).not.toHaveBeenCalled();
3665
+ expect(document.activeElement).toBe(getByLabelText("More actions"));
3666
+ } finally {
3667
+ delete (fsDoc as { exitFullscreen?: unknown }).exitFullscreen;
3668
+ }
3669
+ });
3670
+
3671
+ it("closes an open menu when fullscreen ends", async () => {
3672
+ const { queryByRole, getByLabelText } = await openMenuInFullscreen();
3673
+ await act(async () => {
3674
+ fsElement = null;
3675
+ document.dispatchEvent(new Event("fullscreenchange"));
3676
+ });
3677
+ expect(queryByRole("menu", { name: "More actions" })).toBeNull();
3678
+ expect(document.activeElement).toBe(getByLabelText("More actions"));
3679
+ });
3680
+
3681
+ it("still portals menus to the body when not fullscreen", async () => {
3682
+ const { client } = await buildConnectedSource();
3683
+ const { getByLabelText, getByRole } = renderFeed(client, {
3684
+ flightId: 42,
3685
+ enableFullscreen: true,
3686
+ enablePictureInPicture: true,
3687
+ actions: [
3688
+ { id: "a", label: "Spotlight", icon: null, onClick: () => {} },
3689
+ { id: "b", label: "Snapshot", icon: null, onClick: () => {} },
3690
+ ],
3691
+ });
3692
+ fireEvent.click(getByLabelText("More actions"));
3693
+ const menu = getByRole("menu", { name: "More actions" });
3694
+ expect(menu.parentElement).toBe(document.body);
3695
+ });
3696
+ });