@ksp-gonogo/kerbcast-react 1.6.3 → 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.
Files changed (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +19 -0
  3. package/dist/CameraFeed.d.ts +63 -19
  4. package/dist/CameraFeed.js +329 -202
  5. package/dist/FeedActionBar.d.ts +133 -0
  6. package/dist/FeedActionBar.js +225 -0
  7. package/dist/KerbalFaceFeed.d.ts +82 -0
  8. package/dist/KerbalFaceFeed.js +111 -0
  9. package/dist/RecordingsTray.d.ts +37 -0
  10. package/dist/RecordingsTray.js +279 -0
  11. package/dist/context.d.ts +27 -1
  12. package/dist/context.js +68 -2
  13. package/dist/hooks/useRecordings.d.ts +35 -0
  14. package/dist/hooks/useRecordings.js +33 -0
  15. package/dist/hooks/useReportDisplaySize.d.ts +23 -0
  16. package/dist/hooks/useReportDisplaySize.js +71 -0
  17. package/dist/index.d.ts +12 -2
  18. package/dist/index.js +13 -1
  19. package/dist/menuPositioning.d.ts +20 -0
  20. package/dist/menuPositioning.js +98 -0
  21. package/dist/timing.d.ts +4 -0
  22. package/dist/timing.js +19 -0
  23. package/package.json +2 -2
  24. package/src/CameraFeed.test.tsx +719 -10
  25. package/src/CameraFeed.tsx +586 -334
  26. package/src/FeedActionBar.test.ts +196 -0
  27. package/src/FeedActionBar.test.tsx +215 -0
  28. package/src/FeedActionBar.tsx +391 -0
  29. package/src/KerbalFaceFeed.test.tsx +203 -0
  30. package/src/KerbalFaceFeed.tsx +214 -0
  31. package/src/RecordingsTray.test.tsx +288 -0
  32. package/src/RecordingsTray.tsx +475 -0
  33. package/src/context.test.tsx +94 -0
  34. package/src/context.tsx +98 -1
  35. package/src/hooks/useRecordings.sharedState.test.tsx +117 -0
  36. package/src/hooks/useRecordings.test.tsx +217 -0
  37. package/src/hooks/useRecordings.ts +110 -0
  38. package/src/hooks/useReportDisplaySize.test.tsx +166 -0
  39. package/src/hooks/useReportDisplaySize.ts +91 -0
  40. package/src/index.ts +27 -1
  41. package/src/menuPositioning.ts +126 -0
  42. package/src/timing.test.ts +34 -0
  43. package/src/timing.ts +21 -0
package/src/index.ts CHANGED
@@ -10,16 +10,28 @@
10
10
  export {
11
11
  KerbcastProvider,
12
12
  createClientSubscriptions,
13
+ createClientDisplaySizes,
13
14
  useKerbcastClient,
14
15
  useKerbcastSubscriptions,
16
+ useKerbcastDisplaySizes,
17
+ } from "./context";
18
+ export type {
19
+ KerbcastProviderProps,
20
+ KerbcastSubscriptions,
21
+ KerbcastDisplaySizes,
15
22
  } from "./context";
16
- export type { KerbcastProviderProps, KerbcastSubscriptions } from "./context";
17
23
 
18
24
  // Hooks
19
25
  export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
20
26
  export { useKerbcastStream } from "./hooks/useKerbcastStream";
21
27
  export { useKerbcastClock } from "./hooks/useKerbcastClock";
22
28
  export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
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";
23
35
 
24
36
  // Shared standby glyph (used by CameraFeed + downstream dashboards)
25
37
  export { StandbyIcon } from "./StandbyIcon";
@@ -40,3 +52,17 @@ export type {
40
52
  CameraStreamHook,
41
53
  FeedAction,
42
54
  } from "./CameraFeed";
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
+
62
+ // KerbalFaceFeed — shared single-kerbal-face primitive
63
+ export { KerbalFaceFeed } from "./KerbalFaceFeed";
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
+ }