@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
@@ -1,4 +1,4 @@
1
- import { type EffectiveCommandTrack, type FrameUtSource } from "@ksp-gonogo/kerbcast";
1
+ import { type EffectiveCommandTrack, type FrameAnchor, type FrameUtSource } from "@ksp-gonogo/kerbcast";
2
2
  import { type ReactNode } from "react";
3
3
  /** Default colours; each is overridable through the named CSS custom property. */
4
4
  export declare const COMMAND_BRACKET_COLOURS: {
@@ -22,7 +22,10 @@ export interface BracketPoint {
22
22
  }
23
23
  /** The bracket box, in the pixel space of the bracket SVG (the video element's box). */
24
24
  export interface BracketGeometry {
25
- /** Box centre, which is the aim point. */
25
+ /**
26
+ * Box centre: where the subject is in the displayed frame, or the frame
27
+ * centre (the aim point) while that is not known.
28
+ */
26
29
  cx: number;
27
30
  cy: number;
28
31
  /** Half the box side in display px. */
@@ -35,11 +38,12 @@ export interface BracketGeometry {
35
38
  bottomRight: BracketPoint;
36
39
  }
37
40
  /**
38
- * The bracket box centred on the displayed frame. `bracketHalfPx` is in
39
- * capture px of a `frameHeightPx`-tall frame, so it scales by the displayed
40
- * height; a missing half-size draws the minimum box.
41
+ * The bracket box, centred on the displayed frame or on `centre` (in the
42
+ * same pixel space as `rect`). `bracketHalfPx` is in capture px of a
43
+ * `frameHeightPx`-tall frame, so it scales by the displayed height; a
44
+ * missing half-size draws the minimum box.
41
45
  */
42
- export declare function bracketGeometry(rect: ContentRect, bracketHalfPx: number | null, frameHeightPx: number): BracketGeometry;
46
+ export declare function bracketGeometry(rect: ContentRect, bracketHalfPx: number | null, frameHeightPx: number, centre?: BracketPoint | null): BracketGeometry;
43
47
  export type CommandBracketCorner = "topLeft" | "bottomRight";
44
48
  export interface CommandBracketCornerStyle {
45
49
  colour: string;
@@ -62,6 +66,19 @@ export interface CommandBracketContext {
62
66
  displayUt: number;
63
67
  utSource: FrameUtSource | "override" | null;
64
68
  state: EffectiveCommandTrack;
69
+ /**
70
+ * The subject's anchor in the displayed frame: where the game projected
71
+ * the craft, whether it is in frame and whether something hides it.
72
+ * `null` without one (an older sidecar or plugin, or before the first
73
+ * stamped frame), when the box is centred on the frame instead.
74
+ */
75
+ subject?: FrameAnchor | null;
76
+ /**
77
+ * True while the aim lies off the frame (the view is still turning onto
78
+ * it): the box then sits on the frame's edge nearest the aim, with a
79
+ * chevron pointing on toward it.
80
+ */
81
+ aimOffscreen?: boolean;
65
82
  }
66
83
  /**
67
84
  * Consumer additions to a command feed's brackets. Each part is optional and
@@ -95,15 +112,47 @@ export interface CommandBracketsProps {
95
112
  height: number;
96
113
  } | null;
97
114
  overlay?: CommandBracketOverlay | null;
115
+ /**
116
+ * The subject's anchor in the displayed frame. While it is visible and no
117
+ * command lock is held the box sits on it and takes its size from it.
118
+ */
119
+ subject?: FrameAnchor | null;
120
+ /** The capture frame size `subject` is in (the stamp's width and height). */
121
+ subjectFrameSize?: {
122
+ width: number;
123
+ height: number;
124
+ } | null;
125
+ /**
126
+ * The aim anchor in the displayed frame. The aimed box sits on it rather
127
+ * than on the frame centre, so it stays on the aim while the view turns
128
+ * onto a moved one.
129
+ */
130
+ aim?: FrameAnchor | null;
98
131
  }
99
132
  /**
100
- * Target brackets for a command camera feed: the aim point sits at frame
101
- * centre, so the box is always centred on the displayed video. Both corners
102
- * show the camera axis by default; an overlay can restyle either and draw
103
- * its own marks. Without a fix, or over a blanked feed, the default corners
104
- * are left out but the overlay and readout still run.
133
+ * Target brackets for a command camera feed. With no command lock held and
134
+ * the subject's anchor visible in the displayed frame, the box sits on the
135
+ * craft where the game saw it, so it stays on the craft when an aim points
136
+ * elsewhere; while the aimed box is in frame and holds the craft it is drawn
137
+ * instead, since it frames the craft's whole extent as the view does. Otherwise (the anchor out of frame or occluded, which then
138
+ * carries no position, no anchor at all, or a command authority holding the
139
+ * lock) the box is the 1.11 one: on the aim (the frame centre, or the aim
140
+ * anchor while the view turns onto a moved aim), sized from the aimed range,
141
+ * red and dashed when the aim line is occluded. `ctx.subject` always
142
+ * carries the anchor, so a consumer can draw it as a second mark. An overlay
143
+ * can restyle either corner and draw its own marks. Without a fix, or over a
144
+ * blanked feed, the default corners are left out but the overlay and readout
145
+ * still run.
146
+ */
147
+ export declare function CommandBrackets({ state, displayUt, utSource, video, fallbackSize, overlay, subject, subjectFrameSize, aim, }: CommandBracketsProps): import("react").JSX.Element;
148
+ /**
149
+ * CommandBrackets fed with the subject's and the aim's anchors from each frame of
150
+ * `flightId` that `video` presents. Re-renders at the frame rate, which is
151
+ * why it is its own component.
105
152
  */
106
- export declare function CommandBrackets({ state, displayUt, utSource, video, fallbackSize, overlay, }: CommandBracketsProps): import("react").JSX.Element;
153
+ export declare function LiveCommandBrackets({ flightId, ...props }: CommandBracketsProps & {
154
+ flightId: number | null;
155
+ }): import("react").JSX.Element;
107
156
  /** Reason a command feed is blanked, centred on black over the feed. */
108
157
  export declare function CommandBlank({ reason }: {
109
158
  reason: string;
@@ -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 }) {
@@ -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)