@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
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { commandReadout, } from "@ksp-gonogo/kerbcast";
3
- import { useEffect, useState } from "react";
2
+ import { COMMAND_AIM_ANCHOR_ID, COMMAND_SUBJECT_ANCHOR_ID, KERBCAST_ANCHOR_OWNER, anchorPoint, commandReadout, findAnchor, frameToScreen, } from "@ksp-gonogo/kerbcast";
4
3
  import styled from "styled-components";
4
+ import { useDisplayedFrame } from "./AnchorMarkers";
5
+ import { useVideoBox } from "./videoBox";
5
6
  const BRACKET_STROKE_PX = 2;
6
7
  const BRACKET_MIN_HALF_PX = 14;
7
8
  const BRACKET_ARM_FRACTION = 0.3;
@@ -26,16 +27,17 @@ export function containedContentRect(boxWidth, boxHeight, mediaWidth, mediaHeigh
26
27
  return { x: (boxWidth - width) / 2, y: (boxHeight - height) / 2, width, height };
27
28
  }
28
29
  /**
29
- * The bracket box centred on the displayed frame. `bracketHalfPx` is in
30
- * capture px of a `frameHeightPx`-tall frame, so it scales by the displayed
31
- * height; a missing half-size draws the minimum box.
30
+ * The bracket box, centred on the displayed frame or on `centre` (in the
31
+ * same pixel space as `rect`). `bracketHalfPx` is in capture px of a
32
+ * `frameHeightPx`-tall frame, so it scales by the displayed height; a
33
+ * missing half-size draws the minimum box.
32
34
  */
33
- export function bracketGeometry(rect, bracketHalfPx, frameHeightPx) {
35
+ export function bracketGeometry(rect, bracketHalfPx, frameHeightPx, centre) {
34
36
  const scale = frameHeightPx > 0 ? rect.height / frameHeightPx : 1;
35
37
  const half = Math.max(BRACKET_MIN_HALF_PX, (bracketHalfPx ?? 0) * scale);
36
38
  const arm = Math.max(BRACKET_MIN_ARM_PX, BRACKET_ARM_FRACTION * half * 2);
37
- const cx = rect.x + rect.width / 2;
38
- const cy = rect.y + rect.height / 2;
39
+ const cx = centre ? centre.x : rect.x + rect.width / 2;
40
+ const cy = centre ? centre.y : rect.y + rect.height / 2;
39
41
  return {
40
42
  cx,
41
43
  cy,
@@ -46,8 +48,41 @@ export function bracketGeometry(rect, bracketHalfPx, frameHeightPx) {
46
48
  bottomRight: { x: cx + half, y: cy + half },
47
49
  };
48
50
  }
49
- function cameraCornerStyle(state) {
50
- return state.occluded
51
+ /** Whether `p` lies inside the box `g`. */
52
+ function holds(g, p) {
53
+ return p.x >= g.topLeft.x && p.x <= g.bottomRight.x && p.y >= g.topLeft.y && p.y <= g.bottomRight.y;
54
+ }
55
+ /** `g` moved so the whole box lies inside `rect`. */
56
+ function clampToRect(g, rect) {
57
+ const clamp = (v, lo, hi) => (lo > hi ? (lo + hi) / 2 : Math.min(hi, Math.max(lo, v)));
58
+ const cx = clamp(g.cx, rect.x + g.half, rect.x + rect.width - g.half);
59
+ const cy = clamp(g.cy, rect.y + g.half, rect.y + rect.height - g.half);
60
+ return {
61
+ ...g,
62
+ cx,
63
+ cy,
64
+ topLeft: { x: cx - g.half, y: cy - g.half },
65
+ bottomRight: { x: cx + g.half, y: cy + g.half },
66
+ };
67
+ }
68
+ /** A chevron just outside the box, pointing from its centre toward `toward`. */
69
+ function edgeCue(g, toward) {
70
+ const dx = toward.x - g.cx;
71
+ const dy = toward.y - g.cy;
72
+ const len = Math.hypot(dx, dy);
73
+ if (!(len > 0))
74
+ return null;
75
+ const ux = dx / len;
76
+ const uy = dy / len;
77
+ const tipX = g.cx + ux * (g.half + g.arm);
78
+ const tipY = g.cy + uy * (g.half + g.arm);
79
+ const backX = tipX - ux * g.arm;
80
+ const backY = tipY - uy * g.arm;
81
+ const w = g.arm * 0.6;
82
+ return `M${backX - uy * w} ${backY + ux * w}L${tipX} ${tipY}L${backX + uy * w} ${backY - ux * w}`;
83
+ }
84
+ function cameraCornerStyle(occluded) {
85
+ return occluded
51
86
  ? { colour: COMMAND_BRACKET_COLOURS.occluded, dashed: true }
52
87
  : { colour: COMMAND_BRACKET_COLOURS.clear, dashed: false };
53
88
  }
@@ -58,50 +93,51 @@ function applyCorner(base, override) {
58
93
  hidden: override?.hidden ?? false,
59
94
  };
60
95
  }
61
- /** Tracks the rendered box and intrinsic size of a video element. */
62
- function useVideoBox(video) {
63
- const [box, setBox] = useState({ boxW: 0, boxH: 0, mediaW: 0, mediaH: 0 });
64
- useEffect(() => {
65
- if (!video)
66
- return;
67
- const measure = () => {
68
- const next = {
69
- boxW: video.clientWidth,
70
- boxH: video.clientHeight,
71
- mediaW: video.videoWidth,
72
- mediaH: video.videoHeight,
73
- };
74
- setBox((prev) => prev.boxW === next.boxW &&
75
- prev.boxH === next.boxH &&
76
- prev.mediaW === next.mediaW &&
77
- prev.mediaH === next.mediaH
78
- ? prev
79
- : next);
80
- };
81
- measure();
82
- const observer = new ResizeObserver(measure);
83
- observer.observe(video);
84
- video.addEventListener("loadedmetadata", measure);
85
- video.addEventListener("resize", measure);
86
- return () => {
87
- observer.disconnect();
88
- video.removeEventListener("loadedmetadata", measure);
89
- video.removeEventListener("resize", measure);
90
- };
91
- }, [video]);
92
- return box;
93
- }
94
96
  /**
95
- * Target brackets for a command camera feed: the aim point sits at frame
96
- * centre, so the box is always centred on the displayed video. Both corners
97
- * show the camera axis by default; an overlay can restyle either and draw
98
- * its own marks. Without a fix, or over a blanked feed, the default corners
99
- * are left out but the overlay and readout still run.
97
+ * Target brackets for a command camera feed. With no command lock held and
98
+ * the subject's anchor visible in the displayed frame, the box sits on the
99
+ * craft where the game saw it, so it stays on the craft when an aim points
100
+ * elsewhere; while the aimed box is in frame and holds the craft it is drawn
101
+ * instead, since it frames the craft's whole extent as the view does. Otherwise (the anchor out of frame or occluded, which then
102
+ * carries no position, no anchor at all, or a command authority holding the
103
+ * lock) the box is the 1.11 one: on the aim (the frame centre, or the aim
104
+ * anchor while the view turns onto a moved aim), sized from the aimed range,
105
+ * red and dashed when the aim line is occluded. `ctx.subject` always
106
+ * carries the anchor, so a consumer can draw it as a second mark. An overlay
107
+ * can restyle either corner and draw its own marks. Without a fix, or over a
108
+ * blanked feed, the default corners are left out but the overlay and readout
109
+ * still run.
100
110
  */
101
- export function CommandBrackets({ state, displayUt, utSource, video, fallbackSize, overlay, }) {
111
+ export function CommandBrackets({ state, displayUt, utSource, video, fallbackSize, overlay, subject, subjectFrameSize, aim, }) {
102
112
  const { boxW, boxH, mediaW, mediaH } = useVideoBox(video);
103
113
  const rect = containedContentRect(boxW, boxH, mediaW || fallbackSize?.width || 0, mediaH || fallbackSize?.height || 0);
104
- const g = bracketGeometry(rect, state.bracketHalfPx, state.frameHeightPx || mediaH);
114
+ const frameW = subjectFrameSize?.width || mediaW || fallbackSize?.width || 0;
115
+ const frameH = subjectFrameSize?.height || mediaH || fallbackSize?.height || 0;
116
+ /* Under the lock the box is the authority's aim, as in 1.11. */
117
+ const point = subject && !state.authority && frameW > 0 && frameH > 0 ? anchorPoint(subject) : null;
118
+ const placed = point !== null;
119
+ const aimPoint = aim && frameW > 0 && frameH > 0 ? anchorPoint(aim) : null;
120
+ const aimScreen = aimPoint ? frameToScreen(aimPoint, frameW, frameH, { ...rect, scale: 1 }) : null;
121
+ const aimOffscreen = !point && !!aimScreen && (!aim?.inFrame || !!aim?.behind);
122
+ const aimed = bracketGeometry(rect, state.bracketHalfPx, state.frameHeightPx || mediaH, aimScreen);
123
+ const subjectScreen = point ? frameToScreen(point, frameW, frameH, { ...rect, scale: 1 }) : null;
124
+ /*
125
+ * The aimed box, when it is in frame and holds the subject, frames the
126
+ * craft as the view does: around a craft and its open parachute rather
127
+ * than a sphere about its centre of mass.
128
+ */
129
+ const framed = !!subjectScreen &&
130
+ !!aimScreen &&
131
+ !!aim?.inFrame &&
132
+ !aim?.behind &&
133
+ state.bracketHalfPx != null &&
134
+ holds(aimed, subjectScreen);
135
+ const unclamped = subjectScreen && !framed
136
+ ? bracketGeometry(rect, subject?.apparentRadiusPx ?? null, frameH, subjectScreen)
137
+ : aimed;
138
+ /* An aim off the frame (mid-slew) keeps the box on the edge nearest it, with a cue pointing on. */
139
+ const g = aimOffscreen ? clampToRect(unclamped, rect) : unclamped;
140
+ const cue = aimOffscreen && aimScreen ? edgeCue(g, aimScreen) : null;
105
141
  const ctx = {
106
142
  geometry: g,
107
143
  width: boxW,
@@ -109,18 +145,35 @@ export function CommandBrackets({ state, displayUt, utSource, video, fallbackSiz
109
145
  displayUt,
110
146
  utSource: utSource ?? null,
111
147
  state,
148
+ subject: subject ?? null,
149
+ aimOffscreen,
112
150
  };
113
151
  const overrides = typeof overlay?.corners === "function" ? overlay.corners(ctx) : overlay?.corners;
114
- const camera = cameraCornerStyle(state);
152
+ const camera = cameraCornerStyle(placed ? !!subject?.occluded : state.occluded);
115
153
  const topLeft = applyCorner(camera, overrides?.topLeft);
116
154
  const bottomRight = applyCorner(camera, overrides?.bottomRight);
117
155
  const { x: left, y: top } = g.topLeft;
118
156
  const { x: right, y: bottom } = g.bottomRight;
119
157
  const blanked = !!state.blank;
120
158
  const drawCorners = state.hasFix && !blanked;
121
- const defaultReadout = blanked ? "" : commandReadout(state);
159
+ /* A feed waiting for its target says so itself; NO FIX would only repeat it. */
160
+ const defaultReadout = blanked || state.waitingForTarget ? "" : commandReadout(state);
122
161
  const readout = overlay?.readout ? overlay.readout(defaultReadout, ctx) : defaultReadout;
123
- return (_jsxs(_Fragment, { children: [(drawCorners || overlay?.render) && (_jsxs(BracketSvg, { "aria-hidden": "true", "data-camera-axis": state.camera, width: boxW, height: boxH, children: [drawCorners && !topLeft.hidden && (_jsx("path", { "data-corner": "topLeft", d: `M${left} ${top + g.arm}L${left} ${top}L${left + g.arm} ${top}`, fill: "none", strokeWidth: g.stroke, strokeDasharray: topLeft.dashed ? "3 2" : undefined, style: { stroke: topLeft.colour } })), drawCorners && !bottomRight.hidden && (_jsx("path", { "data-corner": "bottomRight", d: `M${right} ${bottom - g.arm}L${right} ${bottom}L${right - g.arm} ${bottom}`, fill: "none", strokeWidth: g.stroke, strokeDasharray: bottomRight.dashed ? "3 2" : undefined, style: { stroke: bottomRight.colour } })), overlay?.render && _jsx("g", { "data-bracket-overlay": "", children: overlay.render(ctx) })] })), readout && (_jsx(Readout, { "data-ut-source": utSource ?? undefined, style: { left: g.cx, top: bottom + READOUT_GAP_PX }, children: readout }))] }));
162
+ return (_jsxs(_Fragment, { children: [(drawCorners || overlay?.render) && (_jsxs(BracketSvg, { "aria-hidden": "true", "data-camera-axis": state.camera, "data-subject": subject ? (placed ? "placed" : "centred") : undefined, width: boxW, height: boxH, children: [drawCorners && !topLeft.hidden && (_jsx("path", { "data-corner": "topLeft", d: `M${left} ${top + g.arm}L${left} ${top}L${left + g.arm} ${top}`, fill: "none", strokeWidth: g.stroke, strokeDasharray: topLeft.dashed ? "3 2" : undefined, style: { stroke: topLeft.colour } })), drawCorners && !bottomRight.hidden && (_jsx("path", { "data-corner": "bottomRight", d: `M${right} ${bottom - g.arm}L${right} ${bottom}L${right - g.arm} ${bottom}`, fill: "none", strokeWidth: g.stroke, strokeDasharray: bottomRight.dashed ? "3 2" : undefined, style: { stroke: bottomRight.colour } })), drawCorners && cue && (_jsx("path", { "data-aim-cue": "", d: cue, fill: "none", strokeWidth: g.stroke, style: { stroke: camera.colour } })), overlay?.render && _jsx("g", { "data-bracket-overlay": "", children: overlay.render(ctx) })] })), readout && (_jsx(Readout, { "data-ut-source": utSource ?? undefined, style: { left: g.cx, top: bottom + READOUT_GAP_PX }, children: readout }))] }));
163
+ }
164
+ /**
165
+ * CommandBrackets fed with the subject's and the aim's anchors from each frame of
166
+ * `flightId` that `video` presents. Re-renders at the frame rate, which is
167
+ * why it is its own component.
168
+ */
169
+ export function LiveCommandBrackets({ flightId, ...props }) {
170
+ const frame = useDisplayedFrame(true, flightId, props.video);
171
+ const subject = findAnchor(frame?.anchors, COMMAND_SUBJECT_ANCHOR_ID, KERBCAST_ANCHOR_OWNER);
172
+ const aim = findAnchor(frame?.anchors, COMMAND_AIM_ANCHOR_ID, KERBCAST_ANCHOR_OWNER);
173
+ const size = frame?.frameWidth && frame.frameHeight
174
+ ? { width: frame.frameWidth, height: frame.frameHeight }
175
+ : null;
176
+ return _jsx(CommandBrackets, { ...props, subject: subject, subjectFrameSize: size, aim: aim });
124
177
  }
125
178
  /** Reason a command feed is blanked, centred on black over the feed. */
126
179
  export function CommandBlank({ reason }) {
@@ -68,6 +68,7 @@ export interface FeedAction {
68
68
  }
69
69
  interface Partitionable {
70
70
  stateful?: boolean;
71
+ inline?: boolean;
71
72
  order?: number;
72
73
  pinnedTrailing?: boolean;
73
74
  }
@@ -97,6 +98,12 @@ export interface FeedActionBarEntry {
97
98
  id: string;
98
99
  label: string;
99
100
  stateful?: boolean;
101
+ /**
102
+ * Keeps a non-stateful entry out of the ⋮ menu without changing the split:
103
+ * it still counts as non-stateful toward the overflow threshold, so the
104
+ * rest of the bar lays out exactly as it would without the flag.
105
+ */
106
+ inline?: boolean;
100
107
  order?: number;
101
108
  pinnedTrailing?: boolean;
102
109
  render: () => React.ReactNode;
@@ -29,7 +29,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
29
29
  import { Fragment, useId } from "react";
30
30
  import { createPortal } from "react-dom";
31
31
  import styled from "styled-components";
32
- import { usePortalMenu } from "./menuPositioning";
32
+ import { menuPortalRoot, usePortalMenu } from "./menuPositioning";
33
33
  // ---------------------------------------------------------------------------
34
34
  // Partition logic (pure, unit-tested independently of any rendering)
35
35
  // ---------------------------------------------------------------------------
@@ -56,8 +56,8 @@ export function partitionActionBarEntries(entries) {
56
56
  return { primary: overflowable, overflow: [], pinnedTrailing };
57
57
  }
58
58
  return {
59
- primary: overflowable.filter((e) => e.stateful),
60
- overflow: nonStateful,
59
+ primary: overflowable.filter((e) => e.stateful || e.inline),
60
+ overflow: nonStateful.filter((e) => !e.inline),
61
61
  pinnedTrailing,
62
62
  };
63
63
  }
@@ -108,7 +108,7 @@ export function FeedActionBar({ leading, entries, className }) {
108
108
  // the entry's own button click handler, so this closes the
109
109
  // menu once the action has actually fired rather than
110
110
  // pre-empting it.
111
- _jsxs(OverflowMenuItem, { onClick: closeOverflowMenu, children: [e.render(), _jsx(OverflowMenuItemLabel, { "aria-hidden": "true", children: e.label })] }, e.id))) }), document.body), pinnedTrailing.map((e) => (_jsx(Fragment, { children: e.render() }, e.id)))] }));
111
+ _jsxs(OverflowMenuItem, { onClick: closeOverflowMenu, children: [e.render(), _jsx(OverflowMenuItemLabel, { "aria-hidden": "true", children: e.label })] }, e.id))) }), menuPortalRoot()), pinnedTrailing.map((e) => (_jsx(Fragment, { children: e.render() }, e.id)))] }));
112
112
  }
113
113
  // ---------------------------------------------------------------------------
114
114
  // Icons
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Renders ONE kerbal's face-camera stream in a fixed SQUARE frame. It is the
5
5
  * reusable building block a crew bar (kerbcast web) or a mission-control
6
- * surface (gonogo) composes: identity-keyed by `flightId`, it binds the live
6
+ * surface composes: identity-keyed by `flightId`, it binds the live
7
7
  * `MediaStream` to a persistent `<video>` and NEVER remounts that element on
8
8
  * re-layout (the "never remount a live feed" rule — the same srcObject-via-ref
9
9
  * technique `CameraFeed` uses).
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  *
5
5
  * Renders ONE kerbal's face-camera stream in a fixed SQUARE frame. It is the
6
6
  * reusable building block a crew bar (kerbcast web) or a mission-control
7
- * surface (gonogo) composes: identity-keyed by `flightId`, it binds the live
7
+ * surface composes: identity-keyed by `flightId`, it binds the live
8
8
  * `MediaStream` to a persistent `<video>` and NEVER remounts that element on
9
9
  * re-layout (the "never remount a live feed" rule — the same srcObject-via-ref
10
10
  * technique `CameraFeed` uses).
@@ -21,6 +21,7 @@
21
21
  * labeller CameraFeed uses) so this stays decoupled from any particular
22
22
  * camera registry; omit it for a plain "Camera <flightId>" fallback.
23
23
  */
24
+ import type { RecordingEndReason } from "@ksp-gonogo/kerbcast";
24
25
  export interface RecordingsTrayProps {
25
26
  /**
26
27
  * Resolve a flightId to a display label for a clip's camera name. Omit for
@@ -35,3 +36,8 @@ export interface RecordingsTrayProps {
35
36
  * `useRecordings()` directly, same requirement as `CameraFeed`).
36
37
  */
37
38
  export declare function RecordingsTray({ cameraLabel, className }: RecordingsTrayProps): import("react").JSX.Element;
39
+ /**
40
+ * A short line saying why a recording stopped on its own, or null for one a
41
+ * person stopped or that ran its set length.
42
+ */
43
+ export declare function recordingEndNote(endedBy: RecordingEndReason): string | null;
@@ -18,7 +18,7 @@ function ClipRow({ rec, label, url, onDiscard }) {
18
18
  const utRange = rec.utStart != null && rec.utEnd != null
19
19
  ? `${formatUt(rec.utStart)} - ${formatUt(rec.utEnd)}`
20
20
  : "no UT (out of flight)";
21
- return (_jsxs(Clip, { children: [url ? (_jsx(ClipPreview, { controls: true, preload: "metadata", src: url, "aria-label": `Preview: ${label}` })) : (_jsx(ClipPreviewPlaceholder, { "aria-hidden": "true" })), _jsxs(ClipMeta, { children: [_jsx(ClipName, { children: label }), _jsx(ClipDetail, { children: utRange }), _jsxs(ClipDetail, { children: [formatDuration(rec.durationMs), " \u00B7 ", formatBytes(rec.byteSize)] })] }), _jsxs(ClipActions, { children: [url && (_jsx(TrayLink, { href: url, download: clipFileName(rec, label), children: "Download" })), onDiscard && (_jsx(TrayButton, { type: "button", "$danger": true, onClick: onDiscard, children: "Discard" }))] })] }));
21
+ return (_jsxs(Clip, { children: [url ? (_jsx(ClipPreview, { controls: true, preload: "metadata", src: url, "aria-label": `Preview: ${label}` })) : (_jsx(ClipPreviewPlaceholder, { "aria-hidden": "true" })), _jsxs(ClipMeta, { children: [_jsx(ClipName, { children: label }), _jsx(ClipDetail, { children: utRange }), _jsxs(ClipDetail, { children: [formatDuration(rec.durationMs), " \u00B7 ", formatBytes(rec.byteSize)] }), rec.endedBy && recordingEndNote(rec.endedBy) && (_jsx(ClipDetail, { children: recordingEndNote(rec.endedBy) }))] }), _jsxs(ClipActions, { children: [url && (_jsx(TrayLink, { href: url, download: clipFileName(rec, label), children: "Download" })), onDiscard && (_jsx(TrayButton, { type: "button", "$danger": true, onClick: onDiscard, children: "Discard" }))] })] }));
22
22
  }
23
23
  function GroupItem({ group, labelFor, objectUrls, onDiscardGroup }) {
24
24
  return (_jsxs(GroupCard, { "aria-label": `Grouped recording, ${group.recordings.length} feeds`, children: [_jsxs(GroupHeader, { children: [_jsxs(GroupHeaderText, { children: [_jsxs(GroupTitle, { children: ["Grouped recording \u00B7 ", group.recordings.length, " feeds"] }), group.commonUtWindow && (_jsxs(GroupMeta, { children: ["common UT ", formatUt(group.commonUtWindow[0]), " -", " ", formatUt(group.commonUtWindow[1])] }))] }), _jsxs(GroupActions, { children: [_jsx(TrayButton, { type: "button", onClick: () => downloadAll(group.recordings, objectUrls, labelFor), children: "Download all" }), _jsx(TrayButton, { type: "button", "$danger": true, onClick: onDiscardGroup, children: "Discard set" })] })] }), group.recordings.map((rec) => (_jsx(ClipRow, { rec: rec, label: labelFor(rec.flightId), url: objectUrls.get(rec.recordingId) }, rec.recordingId)))] }));
@@ -74,6 +74,22 @@ function useClipObjectUrls(clips) {
74
74
  function formatUt(ut) {
75
75
  return `T+${Math.round(ut)}s`;
76
76
  }
77
+ /**
78
+ * A short line saying why a recording stopped on its own, or null for one a
79
+ * person stopped or that ran its set length.
80
+ */
81
+ export function recordingEndNote(endedBy) {
82
+ switch (endedBy) {
83
+ case "camera-gone":
84
+ return "Stopped: the camera went away";
85
+ case "feed-closed":
86
+ return "Stopped: its feed was closed";
87
+ case "disconnected":
88
+ return "Stopped: the connection was lost";
89
+ default:
90
+ return null;
91
+ }
92
+ }
77
93
  function formatDuration(ms) {
78
94
  const totalSeconds = Math.max(0, Math.round(ms / 1000));
79
95
  const hours = Math.floor(totalSeconds / 3600);
@@ -1,4 +1,4 @@
1
- import { type FrameUtSource, type CommandTrackEvent } from "@ksp-gonogo/kerbcast";
1
+ import { type FrameUtSource, type CommandTrackEvent, type FrameCommandState } from "@ksp-gonogo/kerbcast";
2
2
  export { MAX_UT_EXTRAPOLATION_MS, extrapolateCaptureUt } from "@ksp-gonogo/kerbcast";
3
3
  /** Real milliseconds until the first event after `displayUt`, or `null` when none is ahead. */
4
4
  export declare function msUntilNextEvent(events: readonly CommandTrackEvent[] | undefined, displayUt: number, warpRate: number): number | null;
@@ -8,7 +8,15 @@ export declare function crossesEvent(events: readonly CommandTrackEvent[] | unde
8
8
  export interface CommandDisplayUt {
9
9
  ut: number;
10
10
  source: FrameUtSource | "override";
11
+ /** The command state that rode with the displayed frame (1.12 and later), or the host's own with an override. */
12
+ command?: FrameCommandState;
11
13
  }
14
+ /**
15
+ * What of a frame's command state a viewer can see change: the discrete
16
+ * fields whole, ranges to 3 significant figures, pixel sizes to half a
17
+ * pixel. Frames that agree on it draw the same, so they skip a re-render.
18
+ */
19
+ export declare function commandShownKey(c: FrameCommandState | undefined): string;
12
20
  /**
13
21
  * The UT a command feed is showing. An explicit `override` (a host with its
14
22
  * own delayed playout) wins. Otherwise, given the feed's `flightId` and the
@@ -17,6 +25,11 @@ export interface CommandDisplayUt {
17
25
  * interpolated to now (`"estimated"`). Re-evaluated twice a second while
18
26
  * `enabled` so readout ages keep counting, and again the moment the
19
27
  * displayed UT reaches the next of `events`, so a state change lands on its
20
- * frame rather than on the next tick. `null` when nothing is known.
28
+ * frame rather than on the next tick. The displayed frame's own command
29
+ * state comes back as `command`, re-rendering when what it shows changes:
30
+ * an exact frame's, an estimated one's (which lags the picture, so is never
31
+ * early; its own UT is used too, so events and lists stay on that frame),
32
+ * or with an override the host's own `overrideCommand`. `null` when
33
+ * nothing is known.
21
34
  */
22
- export declare function useCommandDisplayUt(enabled: boolean, override?: number, events?: readonly CommandTrackEvent[], flightId?: number | null, video?: HTMLVideoElement | null): CommandDisplayUt | null;
35
+ export declare function useCommandDisplayUt(enabled: boolean, override?: number, events?: readonly CommandTrackEvent[], flightId?: number | null, video?: HTMLVideoElement | null, overrideCommand?: FrameCommandState | null): CommandDisplayUt | null;
@@ -22,6 +22,35 @@ export function crossesEvent(events, from, to) {
22
22
  const hi = Math.max(from, to);
23
23
  return events.some((e) => e.ut > lo && e.ut <= hi);
24
24
  }
25
+ /**
26
+ * What of a frame's command state a viewer can see change: the discrete
27
+ * fields whole, ranges to 3 significant figures, pixel sizes to half a
28
+ * pixel. Frames that agree on it draw the same, so they skip a re-render.
29
+ */
30
+ export function commandShownKey(c) {
31
+ if (!c)
32
+ return "";
33
+ const sig = (v) => (v == null ? "" : Number(v.toPrecision(3)));
34
+ const half = (v) => (v == null ? "" : Math.round(v * 2));
35
+ return JSON.stringify([
36
+ c.hasFix,
37
+ sig(c.rangeM),
38
+ half(c.bracketHalfPx),
39
+ c.frameHeightPx,
40
+ c.framingFits,
41
+ half(c.trueOffsetPx),
42
+ c.occluded,
43
+ c.occluder,
44
+ c.sunlit,
45
+ c.blank ?? null,
46
+ c.authority?.owner ?? null,
47
+ c.subjectPersistentId,
48
+ c.refusal ?? null,
49
+ c.refusalCode ?? null,
50
+ c.refusalText ?? null,
51
+ c.waitingForTarget ?? false,
52
+ ]);
53
+ }
25
54
  /**
26
55
  * The UT a command feed is showing. An explicit `override` (a host with its
27
56
  * own delayed playout) wins. Otherwise, given the feed's `flightId` and the
@@ -30,9 +59,14 @@ export function crossesEvent(events, from, to) {
30
59
  * interpolated to now (`"estimated"`). Re-evaluated twice a second while
31
60
  * `enabled` so readout ages keep counting, and again the moment the
32
61
  * displayed UT reaches the next of `events`, so a state change lands on its
33
- * frame rather than on the next tick. `null` when nothing is known.
62
+ * frame rather than on the next tick. The displayed frame's own command
63
+ * state comes back as `command`, re-rendering when what it shows changes:
64
+ * an exact frame's, an estimated one's (which lags the picture, so is never
65
+ * early; its own UT is used too, so events and lists stay on that frame),
66
+ * or with an override the host's own `overrideCommand`. `null` when
67
+ * nothing is known.
34
68
  */
35
- export function useCommandDisplayUt(enabled, override, events, flightId, video) {
69
+ export function useCommandDisplayUt(enabled, override, events, flightId, video, overrideCommand) {
36
70
  const client = useKerbcastClient();
37
71
  const sample = useRef({
38
72
  captureUt: client.clock.captureUt,
@@ -58,39 +92,46 @@ export function useCommandDisplayUt(enabled, override, events, flightId, video)
58
92
  const interval = setInterval(tick, TICK_MS);
59
93
  return () => clearInterval(interval);
60
94
  }, [enabled]);
61
- /* Exact frames re-render only when they change what is drawn: a switch of
62
- source, or a step across an event. Ages catch up on the tick. */
95
+ /* Frames re-render only when they change what is drawn: a switch of
96
+ source, a step across an event, or a change the command state shows.
97
+ Ages catch up on the tick. An estimated frame is kept only for the
98
+ command state it carries. */
63
99
  const overriding = override !== undefined;
64
100
  useEffect(() => {
65
101
  frame.current = null;
66
102
  if (!enabled || overriding || flightId == null || !video)
67
103
  return;
68
104
  return client.watchDisplayedFrames(flightId, video, (f) => {
69
- frame.current = f.source === "exact" ? f : null;
105
+ frame.current = f.source === "exact" || f.command ? f : null;
70
106
  const prev = shown.current;
71
107
  if (!frame.current) {
72
- if (prev?.source === "exact")
108
+ if (prev?.source === "exact" || prev?.command)
73
109
  tick();
74
110
  return;
75
111
  }
76
112
  if (prev === null ||
77
- prev.source !== "exact" ||
78
- crossesEvent(eventsRef.current, prev.ut, f.ut)) {
113
+ (f.source === "exact" && prev.source !== "exact") ||
114
+ (f.source !== "exact" && prev.source === "exact") ||
115
+ crossesEvent(eventsRef.current, prev.ut, f.ut) ||
116
+ commandShownKey(prev.command) !== commandShownKey(f.command)) {
79
117
  tick();
80
118
  }
81
119
  });
82
120
  }, [client, enabled, overriding, flightId, video]);
83
121
  const { captureUt, warpRate, at } = sample.current;
84
- const exact = frame.current;
122
+ const seen = frame.current;
123
+ const withCommand = (d, c) => c ? { ...d, command: c } : d;
85
124
  const displayUt = overriding
86
- ? { ut: override, source: "override" }
87
- : exact
88
- ? { ut: exact.ut, source: "exact" }
89
- : captureUt === null
90
- ? null
91
- : { ut: extrapolateCaptureUt(captureUt, warpRate, at, nowMs()), source: "estimated" };
125
+ ? withCommand({ ut: override, source: "override" }, overrideCommand)
126
+ : seen?.source === "exact"
127
+ ? withCommand({ ut: seen.ut, source: "exact" }, seen.command)
128
+ : seen
129
+ ? withCommand({ ut: seen.ut, source: "estimated" }, seen.command)
130
+ : captureUt === null
131
+ ? null
132
+ : { ut: extrapolateCaptureUt(captureUt, warpRate, at, nowMs()), source: "estimated" };
92
133
  shown.current = displayUt;
93
- const untilEdge = enabled && displayUt?.source === "estimated"
134
+ const untilEdge = enabled && displayUt?.source === "estimated" && !seen
94
135
  ? msUntilNextEvent(events, displayUt.ut, warpRate)
95
136
  : null;
96
137
  /* Edges further out than a tick are caught by a later render's timer. */
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Whether this client wants camera `flightId` but has no video slot to carry
3
+ * it (see `KerbcastCameraHandle.waitingForSlot`). False for a null id.
4
+ */
5
+ export declare function useKerbcastWaitingForSlot(flightId: number | null): boolean;
@@ -0,0 +1,20 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useKerbcastClient } from "../context";
3
+ /**
4
+ * Whether this client wants camera `flightId` but has no video slot to carry
5
+ * it (see `KerbcastCameraHandle.waitingForSlot`). False for a null id.
6
+ */
7
+ export function useKerbcastWaitingForSlot(flightId) {
8
+ const client = useKerbcastClient();
9
+ const [waiting, setWaiting] = useState(() => flightId === null ? false : client.camera(flightId).waitingForSlot);
10
+ useEffect(() => {
11
+ if (flightId === null) {
12
+ setWaiting(false);
13
+ return;
14
+ }
15
+ const cam = client.camera(flightId);
16
+ setWaiting(cam.waitingForSlot);
17
+ return cam.on("waiting", setWaiting);
18
+ }, [client, flightId]);
19
+ return waiting;
20
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Whether target brackets are shown for one command camera, remembered per
3
3
  * camera in this browser. Defaults on. `key` is null for a feed with no
4
- * command camera, which reads as on and ignores toggles.
4
+ * command camera, which reads as on and ignores toggles. A preference saved
5
+ * under `legacyKey` (the key an older version used) is moved to `key`.
5
6
  */
6
- export declare function useTargetBracketsPreference(key: string | null): [boolean, () => void];
7
+ export declare function useTargetBracketsPreference(key: string | null, legacyKey?: string | null): [boolean, () => void];
@@ -1,8 +1,20 @@
1
1
  import { useCallback, useMemo, useState } from "react";
2
2
  const STORAGE_PREFIX = "kerbcast:target-brackets:";
3
- function readPreference(key) {
3
+ /* A preference saved under `legacyKey` moves to `key` the first time it is read. */
4
+ function readPreference(key, legacyKey) {
4
5
  try {
5
- return globalThis.localStorage?.getItem(STORAGE_PREFIX + key) !== "0";
6
+ const storage = globalThis.localStorage;
7
+ if (!storage)
8
+ return true;
9
+ const value = storage.getItem(STORAGE_PREFIX + key);
10
+ if (value !== null || legacyKey === null || legacyKey === key)
11
+ return value !== "0";
12
+ const legacy = storage.getItem(STORAGE_PREFIX + legacyKey);
13
+ if (legacy === null)
14
+ return true;
15
+ storage.setItem(STORAGE_PREFIX + key, legacy);
16
+ storage.removeItem(STORAGE_PREFIX + legacyKey);
17
+ return legacy !== "0";
6
18
  }
7
19
  catch {
8
20
  return true;
@@ -22,11 +34,12 @@ function writePreference(key, on) {
22
34
  /**
23
35
  * Whether target brackets are shown for one command camera, remembered per
24
36
  * camera in this browser. Defaults on. `key` is null for a feed with no
25
- * command camera, which reads as on and ignores toggles.
37
+ * command camera, which reads as on and ignores toggles. A preference saved
38
+ * under `legacyKey` (the key an older version used) is moved to `key`.
26
39
  */
27
- export function useTargetBracketsPreference(key) {
40
+ export function useTargetBracketsPreference(key, legacyKey = null) {
28
41
  const [overrides, setOverrides] = useState({});
29
- const stored = useMemo(() => (key === null ? true : readPreference(key)), [key]);
42
+ const stored = useMemo(() => (key === null ? true : readPreference(key, legacyKey)), [key, legacyKey]);
30
43
  const on = key !== null && key in overrides ? overrides[key] : stored;
31
44
  const toggle = useCallback(() => {
32
45
  if (key === null)
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export { useKerbcastStream } from "./hooks/useKerbcastStream";
12
12
  export { useKerbcastClock } from "./hooks/useKerbcastClock";
13
13
  export { useDisplayedFrameUt } from "./hooks/useDisplayedFrameUt";
14
14
  export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
15
+ export { useKerbcastWaitingForSlot } from "./hooks/useKerbcastWaitingForSlot";
15
16
  export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
16
17
  export { useRecordings } from "./hooks/useRecordings";
17
18
  export type { RecordingsStore, ActiveRecordingInfo } from "./hooks/useRecordings";
@@ -25,9 +26,11 @@ export { CameraFeed } from "./CameraFeed";
25
26
  export type { CameraFeedHandle, CameraFeedProps, CameraStreamHook, FeedAction, } from "./CameraFeed";
26
27
  export { bracketGeometry, containedContentRect, COMMAND_BRACKET_COLOURS } from "./CommandBrackets";
27
28
  export type { BracketGeometry, BracketPoint, CommandBracketContext, CommandBracketCorner, CommandBracketCornerOverride, CommandBracketCornerOverrides, CommandBracketCornerStyle, CommandBracketOverlay, ContentRect, } from "./CommandBrackets";
29
+ export { AnchorMarkers, ANCHOR_MARKER_COLOURS, anchorMarkerGeometry, useDisplayedFrame, } from "./AnchorMarkers";
30
+ export type { AnchorContext, AnchorCorner, AnchorCornerOverride, AnchorCornerOverrides, AnchorCornerStyle, AnchorMarkerGeometry, AnchorMarkerShape, AnchorMarkersProps, AnchorOverlay, PlacedAnchor, } from "./AnchorMarkers";
28
31
  export { FeedActionBar } from "./FeedActionBar";
29
32
  export type { FeedActionBarEntry } from "./FeedActionBar";
30
33
  export { KerbalFaceFeed } from "./KerbalFaceFeed";
31
34
  export type { KerbalFaceFeedProps, KerbalFeedState } from "./KerbalFaceFeed";
32
- export { RecordingsTray } from "./RecordingsTray";
35
+ export { RecordingsTray, recordingEndNote } from "./RecordingsTray";
33
36
  export type { RecordingsTrayProps } from "./RecordingsTray";
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ export { useKerbcastStream } from "./hooks/useKerbcastStream";
13
13
  export { useKerbcastClock } from "./hooks/useKerbcastClock";
14
14
  export { useDisplayedFrameUt } from "./hooks/useDisplayedFrameUt";
15
15
  export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
16
+ export { useKerbcastWaitingForSlot } from "./hooks/useKerbcastWaitingForSlot";
16
17
  export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
17
18
  export { useRecordings } from "./hooks/useRecordings";
18
19
  // Shared elapsed-timer helpers (REC badges, RecGroupBar's toolbar)
@@ -27,6 +28,9 @@ export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
27
28
  export { CameraFeed } from "./CameraFeed";
28
29
  // Target brackets geometry and the overlay API for drawing on top of them
29
30
  export { bracketGeometry, containedContentRect, COMMAND_BRACKET_COLOURS } from "./CommandBrackets";
31
+ // Anchored markers: CameraFeed's `anchors` and `anchorOverlay`, and the
32
+ // pieces for drawing a frame's anchors over a video of your own
33
+ export { AnchorMarkers, ANCHOR_MARKER_COLOURS, anchorMarkerGeometry, useDisplayedFrame, } from "./AnchorMarkers";
30
34
  // FeedActionBar: the shared action row (tracking, fullscreen, PiP, quality,
31
35
  // close, REC) both CameraFeed and KerbalFaceFeed render, with the issue-#6
32
36
  // overflow (⋮) engine.
@@ -34,4 +38,4 @@ export { FeedActionBar } from "./FeedActionBar";
34
38
  // KerbalFaceFeed — shared single-kerbal-face primitive
35
39
  export { KerbalFaceFeed } from "./KerbalFaceFeed";
36
40
  // RecordingsTray: a presentational drawer over the recordings store
37
- export { RecordingsTray } from "./RecordingsTray";
41
+ export { RecordingsTray, recordingEndNote } from "./RecordingsTray";
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Shared geometry + behaviour for a dropdown portaled to document.body.
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
  */
@@ -10,6 +10,12 @@ export interface MenuAnchor {
10
10
  align: "start" | "end";
11
11
  }
12
12
  export declare function computeMenuPosition(trigger: HTMLElement, anchor: MenuAnchor): React.CSSProperties;
13
+ /**
14
+ * Where a dropdown portals to. Only the fullscreen element and its
15
+ * descendants render while something is fullscreen, so a menu portaled to
16
+ * document.body then would open invisibly behind it.
17
+ */
18
+ export declare function menuPortalRoot(): Element;
13
19
  export declare function usePortalMenu({ maxWidth, maxHeight, align }: MenuAnchor): {
14
20
  open: boolean;
15
21
  toggle: () => void;