@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.
- package/CHANGELOG.md +54 -0
- package/README.md +19 -0
- package/dist/CameraFeed.d.ts +63 -19
- package/dist/CameraFeed.js +329 -202
- package/dist/FeedActionBar.d.ts +133 -0
- package/dist/FeedActionBar.js +225 -0
- package/dist/KerbalFaceFeed.d.ts +82 -0
- package/dist/KerbalFaceFeed.js +111 -0
- package/dist/RecordingsTray.d.ts +37 -0
- package/dist/RecordingsTray.js +279 -0
- package/dist/context.d.ts +27 -1
- package/dist/context.js +68 -2
- package/dist/hooks/useRecordings.d.ts +35 -0
- package/dist/hooks/useRecordings.js +33 -0
- package/dist/hooks/useReportDisplaySize.d.ts +23 -0
- package/dist/hooks/useReportDisplaySize.js +71 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +13 -1
- package/dist/menuPositioning.d.ts +20 -0
- package/dist/menuPositioning.js +98 -0
- package/dist/timing.d.ts +4 -0
- package/dist/timing.js +19 -0
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +719 -10
- package/src/CameraFeed.tsx +586 -334
- package/src/FeedActionBar.test.ts +196 -0
- package/src/FeedActionBar.test.tsx +215 -0
- package/src/FeedActionBar.tsx +391 -0
- package/src/KerbalFaceFeed.test.tsx +203 -0
- package/src/KerbalFaceFeed.tsx +214 -0
- package/src/RecordingsTray.test.tsx +288 -0
- package/src/RecordingsTray.tsx +475 -0
- package/src/context.test.tsx +94 -0
- package/src/context.tsx +98 -1
- package/src/hooks/useRecordings.sharedState.test.tsx +117 -0
- package/src/hooks/useRecordings.test.tsx +217 -0
- package/src/hooks/useRecordings.ts +110 -0
- package/src/hooks/useReportDisplaySize.test.tsx +166 -0
- package/src/hooks/useReportDisplaySize.ts +91 -0
- package/src/index.ts +27 -1
- package/src/menuPositioning.ts +126 -0
- package/src/timing.test.ts +34 -0
- package/src/timing.ts +21 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FeedActionBar: the shared top-right action row rendered by BOTH
|
|
3
|
+
* `CameraFeed` and `KerbalFaceFeed`, so a single overflow engine serves every
|
|
4
|
+
* feed surface (part cams, crew faces, and any future host).
|
|
5
|
+
*
|
|
6
|
+
* Partition rule (issue #6 UI spec): stateful toggles (their at-rest state
|
|
7
|
+
* must stay visible, e.g. auto-track, the coming REC) are PRIMARY and always
|
|
8
|
+
* inline. Non-stateful actions (quality, PiP, fullscreen, plain consumer
|
|
9
|
+
* actions) are overflow-eligible: they collapse into a ⋮ menu ONLY once the
|
|
10
|
+
* row is genuinely crowded (total enabled >= 4 AND >= 2 non-stateful
|
|
11
|
+
* eligible): a single item never collapses, since that would cost a click
|
|
12
|
+
* to reach the one control that exists. Order is stable (an explicit `order`
|
|
13
|
+
* wins, otherwise entries keep the relative position their caller gave them)
|
|
14
|
+
* so chips don't reflow as buttons enable/disable around them.
|
|
15
|
+
*
|
|
16
|
+
* Close/remove is a THIRD category: `pinnedTrailing`. It is never
|
|
17
|
+
* overflow-eligible, never counted toward the overflow threshold, and always
|
|
18
|
+
* renders last (to the right of the ⋮ trigger itself), so dismissing a
|
|
19
|
+
* feed always stays a single, persistent, rightmost click. Bar order is:
|
|
20
|
+
* [stateful primaries] … [⋮ overflow, non-stateful] [pinned trailing close].
|
|
21
|
+
*
|
|
22
|
+
* Deliberately minimal: an entry is just (id, label, stateful?, order?,
|
|
23
|
+
* pinnedTrailing?, render). The interactive node itself (icon, aria
|
|
24
|
+
* attributes, onClick, any submenu it opens) is built by the caller
|
|
25
|
+
* (CameraFeed / KerbalFaceFeed) exactly as before; this component only
|
|
26
|
+
* decides WHERE it goes.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* A consumer-supplied action rendered into a feed's action bar. Lets a host
|
|
30
|
+
* page (e.g. the sidecar's spotlight toggle) inject controls without the
|
|
31
|
+
* library knowing what they do.
|
|
32
|
+
*/
|
|
33
|
+
export interface FeedAction {
|
|
34
|
+
/** Stable identity for the React key. */
|
|
35
|
+
id: string;
|
|
36
|
+
/** Accessible label; used for aria-label, the native tooltip, and the overflow-menu row's visible text. */
|
|
37
|
+
label: string;
|
|
38
|
+
/** Icon node, sized by the action bar (~14px). */
|
|
39
|
+
icon: React.ReactNode;
|
|
40
|
+
/** Toggle state: renders the button highlighted and sets aria-pressed. */
|
|
41
|
+
active?: boolean;
|
|
42
|
+
onClick: () => void;
|
|
43
|
+
/**
|
|
44
|
+
* Whether this action is a stateful toggle: stays PRIMARY / always
|
|
45
|
+
* inline so its at-rest state is visible, never collapses into the ⋮
|
|
46
|
+
* overflow. Defaults to `active !== undefined`. Override when a
|
|
47
|
+
* tri-state/menu-driven action's "active" isn't a plain boolean, or to force
|
|
48
|
+
* a boolean toggle to be overflow-eligible anyway.
|
|
49
|
+
*/
|
|
50
|
+
stateful?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Stable sort key for the action bar (lower sorts first). Actions without
|
|
53
|
+
* an explicit order keep their natural (array) position; this exists so a
|
|
54
|
+
* consumer can pin an action to a specific slot instead. Chips never
|
|
55
|
+
* reflow as buttons enable/disable; this is what keeps that stable.
|
|
56
|
+
*/
|
|
57
|
+
order?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Marks this as the bar's pinned-trailing (close/remove) slot: it is NEVER
|
|
60
|
+
* overflow-eligible, is excluded entirely from the overflow-threshold count
|
|
61
|
+
* (both the total and the non-stateful-eligible count), and always renders
|
|
62
|
+
* last (to the right of the ⋮ trigger), so dismissing a feed is always a
|
|
63
|
+
* single, persistent, rightmost click. `CameraFeed`'s `trailingActions`
|
|
64
|
+
* prop sets this automatically; a `KerbalFaceFeed`/`actions`-only consumer
|
|
65
|
+
* sets it explicitly on its close action.
|
|
66
|
+
*/
|
|
67
|
+
pinnedTrailing?: boolean;
|
|
68
|
+
}
|
|
69
|
+
interface Partitionable {
|
|
70
|
+
stateful?: boolean;
|
|
71
|
+
order?: number;
|
|
72
|
+
pinnedTrailing?: boolean;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Sort a list of action-bar entries into (primary, overflow, pinnedTrailing).
|
|
76
|
+
* `entries` must already be filtered down to the currently-enabled set:
|
|
77
|
+
* this function has no notion of "disabled", only "present".
|
|
78
|
+
*
|
|
79
|
+
* `pinnedTrailing` entries (close/remove) are pulled out FIRST and excluded
|
|
80
|
+
* entirely from the overflow-threshold count. Both the total and the
|
|
81
|
+
* non-stateful-eligible count run only over the remaining entries, then
|
|
82
|
+
* always returned as their own group, to be rendered last (see
|
|
83
|
+
* `FeedActionBar`, which places them after the ⋮ trigger).
|
|
84
|
+
*/
|
|
85
|
+
export declare function partitionActionBarEntries<T extends Partitionable>(entries: readonly T[]): {
|
|
86
|
+
primary: T[];
|
|
87
|
+
overflow: T[];
|
|
88
|
+
pinnedTrailing: T[];
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* One slot in the bar. `render` produces the actual interactive element
|
|
92
|
+
* (unchanged whether it lands inline or inside the overflow menu) so callers
|
|
93
|
+
* keep full control of refs / aria attributes / submenus; FeedActionBar only
|
|
94
|
+
* decides placement and, in the overflow menu, adds the visible label.
|
|
95
|
+
*/
|
|
96
|
+
export interface FeedActionBarEntry {
|
|
97
|
+
id: string;
|
|
98
|
+
label: string;
|
|
99
|
+
stateful?: boolean;
|
|
100
|
+
order?: number;
|
|
101
|
+
pinnedTrailing?: boolean;
|
|
102
|
+
render: () => React.ReactNode;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Adapt a plain consumer/public `FeedAction` (id/label/icon/active/onClick,
|
|
106
|
+
* plus the optional stateful?/order?/pinnedTrailing? flags) into a bar entry,
|
|
107
|
+
* rendered as the standard icon button. `stateful` defaults to
|
|
108
|
+
* `active !== undefined` per the #6 spec (stateful toggles, meaning
|
|
109
|
+
* `active !== undefined`, stay primary).
|
|
110
|
+
*/
|
|
111
|
+
export declare function feedActionToEntry(action: FeedAction): FeedActionBarEntry;
|
|
112
|
+
export interface FeedActionBarProps {
|
|
113
|
+
/** Non-partitioned content rendered before the partitioned entries (e.g. the camera step buttons). */
|
|
114
|
+
leading?: React.ReactNode;
|
|
115
|
+
entries: FeedActionBarEntry[];
|
|
116
|
+
/** Forwarded to the root row; lets a host `styled(FeedActionBar)` override layout (e.g. KerbalFaceFeed's smaller, always-visible bar). */
|
|
117
|
+
className?: string;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Shared top-right action row. Renders `leading`, then the partitioned
|
|
121
|
+
* entries: stateful ones always inline, non-stateful ones inline until the
|
|
122
|
+
* row is crowded enough to warrant a ⋮ overflow menu, then the pinned
|
|
123
|
+
* trailing (close/remove) entries: always last, to the right of the ⋮.
|
|
124
|
+
*/
|
|
125
|
+
export declare function FeedActionBar({ leading, entries, className }: FeedActionBarProps): import("react").JSX.Element | null;
|
|
126
|
+
/** Icon-button shared by every action-bar entry (built-in or consumer). */
|
|
127
|
+
export declare const OverlayIconButton: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components").FastOmit<import("styled-components").FastOmit<import("react").DetailedHTMLProps<import("react").ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>, "$active"> & {
|
|
128
|
+
$active?: boolean;
|
|
129
|
+
}, never> & Partial<Pick<import("styled-components").FastOmit<import("react").DetailedHTMLProps<import("react").ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>, "$active"> & {
|
|
130
|
+
$active?: boolean;
|
|
131
|
+
}, never>>> & string;
|
|
132
|
+
export declare const ActionBarRow: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components").FastOmit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, never> & Partial<Pick<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, never>>> & string;
|
|
133
|
+
export {};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* FeedActionBar: the shared top-right action row rendered by BOTH
|
|
4
|
+
* `CameraFeed` and `KerbalFaceFeed`, so a single overflow engine serves every
|
|
5
|
+
* feed surface (part cams, crew faces, and any future host).
|
|
6
|
+
*
|
|
7
|
+
* Partition rule (issue #6 UI spec): stateful toggles (their at-rest state
|
|
8
|
+
* must stay visible, e.g. auto-track, the coming REC) are PRIMARY and always
|
|
9
|
+
* inline. Non-stateful actions (quality, PiP, fullscreen, plain consumer
|
|
10
|
+
* actions) are overflow-eligible: they collapse into a ⋮ menu ONLY once the
|
|
11
|
+
* row is genuinely crowded (total enabled >= 4 AND >= 2 non-stateful
|
|
12
|
+
* eligible): a single item never collapses, since that would cost a click
|
|
13
|
+
* to reach the one control that exists. Order is stable (an explicit `order`
|
|
14
|
+
* wins, otherwise entries keep the relative position their caller gave them)
|
|
15
|
+
* so chips don't reflow as buttons enable/disable around them.
|
|
16
|
+
*
|
|
17
|
+
* Close/remove is a THIRD category: `pinnedTrailing`. It is never
|
|
18
|
+
* overflow-eligible, never counted toward the overflow threshold, and always
|
|
19
|
+
* renders last (to the right of the ⋮ trigger itself), so dismissing a
|
|
20
|
+
* feed always stays a single, persistent, rightmost click. Bar order is:
|
|
21
|
+
* [stateful primaries] … [⋮ overflow, non-stateful] [pinned trailing close].
|
|
22
|
+
*
|
|
23
|
+
* Deliberately minimal: an entry is just (id, label, stateful?, order?,
|
|
24
|
+
* pinnedTrailing?, render). The interactive node itself (icon, aria
|
|
25
|
+
* attributes, onClick, any submenu it opens) is built by the caller
|
|
26
|
+
* (CameraFeed / KerbalFaceFeed) exactly as before; this component only
|
|
27
|
+
* decides WHERE it goes.
|
|
28
|
+
*/
|
|
29
|
+
import { Fragment, useId } from "react";
|
|
30
|
+
import { createPortal } from "react-dom";
|
|
31
|
+
import styled from "styled-components";
|
|
32
|
+
import { usePortalMenu } from "./menuPositioning";
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Partition logic (pure, unit-tested independently of any rendering)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
const OVERFLOW_MIN_TOTAL = 4;
|
|
37
|
+
const OVERFLOW_MIN_NON_STATEFUL = 2;
|
|
38
|
+
/**
|
|
39
|
+
* Sort a list of action-bar entries into (primary, overflow, pinnedTrailing).
|
|
40
|
+
* `entries` must already be filtered down to the currently-enabled set:
|
|
41
|
+
* this function has no notion of "disabled", only "present".
|
|
42
|
+
*
|
|
43
|
+
* `pinnedTrailing` entries (close/remove) are pulled out FIRST and excluded
|
|
44
|
+
* entirely from the overflow-threshold count. Both the total and the
|
|
45
|
+
* non-stateful-eligible count run only over the remaining entries, then
|
|
46
|
+
* always returned as their own group, to be rendered last (see
|
|
47
|
+
* `FeedActionBar`, which places them after the ⋮ trigger).
|
|
48
|
+
*/
|
|
49
|
+
export function partitionActionBarEntries(entries) {
|
|
50
|
+
const sorted = [...entries].sort((a, b) => (a.order ?? Number.POSITIVE_INFINITY) - (b.order ?? Number.POSITIVE_INFINITY));
|
|
51
|
+
const pinnedTrailing = sorted.filter((e) => e.pinnedTrailing);
|
|
52
|
+
const overflowable = sorted.filter((e) => !e.pinnedTrailing);
|
|
53
|
+
const nonStateful = overflowable.filter((e) => !e.stateful);
|
|
54
|
+
const overflowActive = overflowable.length >= OVERFLOW_MIN_TOTAL && nonStateful.length >= OVERFLOW_MIN_NON_STATEFUL;
|
|
55
|
+
if (!overflowActive) {
|
|
56
|
+
return { primary: overflowable, overflow: [], pinnedTrailing };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
primary: overflowable.filter((e) => e.stateful),
|
|
60
|
+
overflow: nonStateful,
|
|
61
|
+
pinnedTrailing,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Adapt a plain consumer/public `FeedAction` (id/label/icon/active/onClick,
|
|
66
|
+
* plus the optional stateful?/order?/pinnedTrailing? flags) into a bar entry,
|
|
67
|
+
* rendered as the standard icon button. `stateful` defaults to
|
|
68
|
+
* `active !== undefined` per the #6 spec (stateful toggles, meaning
|
|
69
|
+
* `active !== undefined`, stay primary).
|
|
70
|
+
*/
|
|
71
|
+
export function feedActionToEntry(action) {
|
|
72
|
+
return {
|
|
73
|
+
id: action.id,
|
|
74
|
+
label: action.label,
|
|
75
|
+
stateful: action.stateful ?? action.active !== undefined,
|
|
76
|
+
order: action.order,
|
|
77
|
+
pinnedTrailing: action.pinnedTrailing,
|
|
78
|
+
render: () => (_jsx(OverlayIconButton, { type: "button", "aria-label": action.label, "aria-pressed": action.active ?? undefined, title: action.label, "$active": action.active ?? false, onClick: action.onClick, children: action.icon }, action.id)),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// The bar itself
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
const OVERFLOW_MENU_MAX_WIDTH = 200;
|
|
85
|
+
const OVERFLOW_MENU_MAX_HEIGHT = 240;
|
|
86
|
+
/**
|
|
87
|
+
* Shared top-right action row. Renders `leading`, then the partitioned
|
|
88
|
+
* entries: stateful ones always inline, non-stateful ones inline until the
|
|
89
|
+
* row is crowded enough to warrant a ⋮ overflow menu, then the pinned
|
|
90
|
+
* trailing (close/remove) entries: always last, to the right of the ⋮.
|
|
91
|
+
*/
|
|
92
|
+
export function FeedActionBar({ leading, entries, className }) {
|
|
93
|
+
const { primary, overflow, pinnedTrailing } = partitionActionBarEntries(entries);
|
|
94
|
+
const menuId = useId();
|
|
95
|
+
const overflowMenu = usePortalMenu({
|
|
96
|
+
maxWidth: OVERFLOW_MENU_MAX_WIDTH,
|
|
97
|
+
maxHeight: OVERFLOW_MENU_MAX_HEIGHT,
|
|
98
|
+
align: "end",
|
|
99
|
+
});
|
|
100
|
+
const closeOverflowMenu = overflowMenu.close;
|
|
101
|
+
if (!leading && entries.length === 0)
|
|
102
|
+
return null;
|
|
103
|
+
return (_jsxs(ActionBarRow, { className: className, children: [leading, primary.map((e) => (_jsx(Fragment, { children: e.render() }, e.id))), overflow.length > 0 && (_jsx(OverlayIconButton, { ref: overflowMenu.triggerRef, type: "button", "aria-label": "More actions", "aria-haspopup": "menu", "aria-expanded": overflowMenu.open, "aria-controls": menuId, title: "More actions", "$active": overflowMenu.open, onClick: overflowMenu.toggle, children: _jsx(MoreIcon, {}) })), overflow.length > 0 &&
|
|
104
|
+
overflowMenu.open &&
|
|
105
|
+
overflowMenu.position &&
|
|
106
|
+
createPortal(_jsx(OverflowMenu, { ref: overflowMenu.menuRef, id: menuId, role: "menu", "aria-label": "More actions", style: overflowMenu.position, children: overflow.map((e) => (
|
|
107
|
+
// The wrapping row's onClick fires in the BUBBLE phase, after
|
|
108
|
+
// the entry's own button click handler, so this closes the
|
|
109
|
+
// menu once the action has actually fired rather than
|
|
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)))] }));
|
|
112
|
+
}
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Icons
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
/* Overflow trigger: vertical ellipsis (three dots). */
|
|
117
|
+
function MoreIcon() {
|
|
118
|
+
return (_jsxs("svg", { viewBox: "0 0 16 16", width: 14, height: 14, fill: "currentColor", stroke: "none", "aria-hidden": "true", children: [_jsx("circle", { cx: "8", cy: "3", r: "1.4" }), _jsx("circle", { cx: "8", cy: "8", r: "1.4" }), _jsx("circle", { cx: "8", cy: "13", r: "1.4" })] }));
|
|
119
|
+
}
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Styled components
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
/** Icon-button shared by every action-bar entry (built-in or consumer). */
|
|
124
|
+
export const OverlayIconButton = styled.button `
|
|
125
|
+
position: relative;
|
|
126
|
+
width: 24px;
|
|
127
|
+
height: 24px;
|
|
128
|
+
display: flex;
|
|
129
|
+
align-items: center;
|
|
130
|
+
justify-content: center;
|
|
131
|
+
padding: 0;
|
|
132
|
+
/* Active-toggle fill (quality / tracking / PiP / fullscreen / custom actions
|
|
133
|
+
all share this button). Its own token so a consumer can recolour just the
|
|
134
|
+
action-row highlight without touching the general accent; defaults to
|
|
135
|
+
--kerbcast-accent, so the sidecar web page (which maps its accent onto that)
|
|
136
|
+
is unchanged. */
|
|
137
|
+
background: ${(p) => p.$active
|
|
138
|
+
? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
|
|
139
|
+
: "rgba(0, 0, 0, 0.5)"};
|
|
140
|
+
border: 1px solid
|
|
141
|
+
${(p) => p.$active
|
|
142
|
+
? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
|
|
143
|
+
: "rgba(255, 255, 255, 0.3)"};
|
|
144
|
+
border-radius: 3px;
|
|
145
|
+
color: ${(p) => (p.$active ? "#000" : "#fff")};
|
|
146
|
+
cursor: pointer;
|
|
147
|
+
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
|
148
|
+
|
|
149
|
+
@media (hover: hover) {
|
|
150
|
+
&:hover {
|
|
151
|
+
background: ${(p) => p.$active
|
|
152
|
+
? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
|
|
153
|
+
: "rgba(0, 0, 0, 0.7)"};
|
|
154
|
+
border-color: rgba(255, 255, 255, 0.6);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
&:focus-visible {
|
|
159
|
+
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
160
|
+
outline-offset: 2px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
&:disabled {
|
|
164
|
+
opacity: 0.4;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@media (prefers-reduced-motion: reduce) {
|
|
168
|
+
transition: none;
|
|
169
|
+
}
|
|
170
|
+
`;
|
|
171
|
+
/* Top-right cluster of overlay controls (custom actions + built-ins + overflow). */
|
|
172
|
+
export const ActionBarRow = styled.div `
|
|
173
|
+
position: absolute;
|
|
174
|
+
top: 6px;
|
|
175
|
+
right: 8px;
|
|
176
|
+
z-index: 3;
|
|
177
|
+
display: flex;
|
|
178
|
+
align-items: center;
|
|
179
|
+
gap: 4px;
|
|
180
|
+
opacity: 0;
|
|
181
|
+
pointer-events: none;
|
|
182
|
+
transition: opacity 0.15s;
|
|
183
|
+
|
|
184
|
+
@media (prefers-reduced-motion: reduce) {
|
|
185
|
+
transition: none;
|
|
186
|
+
}
|
|
187
|
+
`;
|
|
188
|
+
/* The ⋮ overflow dropdown: same portal/anchor contract as CameraFeed's other
|
|
189
|
+
menus (fixed position set inline from the trigger rect) so tile clipping
|
|
190
|
+
cannot cut it off. */
|
|
191
|
+
const OverflowMenu = styled.div `
|
|
192
|
+
position: fixed;
|
|
193
|
+
z-index: 1000;
|
|
194
|
+
min-width: 140px;
|
|
195
|
+
max-width: min(200px, calc(100vw - 16px));
|
|
196
|
+
max-height: min(40vh, 240px);
|
|
197
|
+
display: flex;
|
|
198
|
+
flex-direction: column;
|
|
199
|
+
padding: 4px;
|
|
200
|
+
gap: 2px;
|
|
201
|
+
background: rgba(0, 0, 0, 0.85);
|
|
202
|
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
203
|
+
border-radius: 4px;
|
|
204
|
+
overflow-x: hidden;
|
|
205
|
+
overflow-y: auto;
|
|
206
|
+
`;
|
|
207
|
+
const OverflowMenuItem = styled.div `
|
|
208
|
+
display: flex;
|
|
209
|
+
align-items: center;
|
|
210
|
+
gap: 8px;
|
|
211
|
+
padding: 2px 4px;
|
|
212
|
+
border-radius: 3px;
|
|
213
|
+
|
|
214
|
+
@media (hover: hover) {
|
|
215
|
+
&:hover {
|
|
216
|
+
background: rgba(255, 255, 255, 0.1);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
`;
|
|
220
|
+
const OverflowMenuItemLabel = styled.span `
|
|
221
|
+
font-size: 11px;
|
|
222
|
+
letter-spacing: 0.04em;
|
|
223
|
+
color: #fff;
|
|
224
|
+
white-space: nowrap;
|
|
225
|
+
`;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KerbalFaceFeed — the shared single-kerbal-face primitive.
|
|
3
|
+
*
|
|
4
|
+
* Renders ONE kerbal's face-camera stream in a fixed SQUARE frame. It is the
|
|
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
|
|
7
|
+
* `MediaStream` to a persistent `<video>` and NEVER remounts that element on
|
|
8
|
+
* re-layout (the "never remount a live feed" rule — the same srcObject-via-ref
|
|
9
|
+
* technique `CameraFeed` uses).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately minimal chrome: just the square live face plus a standby glyph
|
|
12
|
+
* when there is no live signal. Everything else — name label, IVA/EVA badge,
|
|
13
|
+
* click behaviour, a "SIGNAL LOST" treatment — is layered OVER it by the
|
|
14
|
+
* wrapper via `children`, driven by the `onFeedStateChange` signal. A thin
|
|
15
|
+
* `actions` bar (the shared `FeedAction` shape) is rendered top-right when a
|
|
16
|
+
* wrapper supplies one, so declarative controls compose the same way they do
|
|
17
|
+
* on `CameraFeed`.
|
|
18
|
+
*/
|
|
19
|
+
import { CameraStreamHook, FeedAction } from "./CameraFeed";
|
|
20
|
+
/**
|
|
21
|
+
* Coarse presentation state a wrapper reacts to for its own fallback/badge:
|
|
22
|
+
* - `live`: a stream is bound and playing.
|
|
23
|
+
* - `no-signal`: no stream yet (connecting, out of flight, or not visible).
|
|
24
|
+
* - `destroyed`: the camera's part/kerbal is gone (the wrapper renders SIGNAL
|
|
25
|
+
* LOST; the last frame stays on-screen because the `<video>` is not cleared).
|
|
26
|
+
*/
|
|
27
|
+
export type KerbalFeedState = "live" | "no-signal" | "destroyed";
|
|
28
|
+
export interface KerbalFaceFeedProps {
|
|
29
|
+
/** Wire-id (`CameraState.flightId`) of the kerbal face camera to display. */
|
|
30
|
+
flightId: number;
|
|
31
|
+
/**
|
|
32
|
+
* Explicit square side in px, for LAYOUT only. When omitted the feed fills
|
|
33
|
+
* its parent's width at a 1:1 aspect ratio. This does NOT drive resolution:
|
|
34
|
+
* the primitive always self-measures its rendered box and reports that to the
|
|
35
|
+
* sidecar (auto-resolution). Pass it to fix the element's size, not to
|
|
36
|
+
* request a stream resolution.
|
|
37
|
+
*/
|
|
38
|
+
size?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Report the self-measured display size to the sidecar to drive
|
|
41
|
+
* auto-resolution. Default true. Set false for a fixed-resolution feed that
|
|
42
|
+
* should not participate in auto-res. Independent of {@link showActions}
|
|
43
|
+
* (a resolution concern, not a UI one).
|
|
44
|
+
*/
|
|
45
|
+
reportSize?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Consumer action buttons, rendered as a minimal top-right bar (the shared
|
|
48
|
+
* {@link FeedAction} shape). Omit for no bar — the common case, since a crew
|
|
49
|
+
* bar usually composes its controls in `children` instead.
|
|
50
|
+
*/
|
|
51
|
+
actions?: FeedAction[];
|
|
52
|
+
/**
|
|
53
|
+
* Render the feed's action UI (the top-right action bar). Default true. Set
|
|
54
|
+
* false to suppress the bar entirely (e.g. a tiny avatar where hover controls
|
|
55
|
+
* add nothing). Does not affect display-size reporting.
|
|
56
|
+
*/
|
|
57
|
+
showActions?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Overlay content layered OVER the square face: name label, IVA/EVA badge,
|
|
60
|
+
* click target, a custom destroyed treatment. Absolutely positioned to fill
|
|
61
|
+
* the frame; the primitive keeps its own chrome out of the way.
|
|
62
|
+
*/
|
|
63
|
+
children?: React.ReactNode;
|
|
64
|
+
/**
|
|
65
|
+
* Fired whenever the derived feed state changes, so the wrapper can swap its
|
|
66
|
+
* fallback/badge without polling. Fires once on mount with the initial state.
|
|
67
|
+
*/
|
|
68
|
+
onFeedStateChange?: (state: KerbalFeedState) => void;
|
|
69
|
+
/**
|
|
70
|
+
* Draw the built-in standby glyph while not `live`. Default true. Set false
|
|
71
|
+
* when the wrapper renders its own out-of-signal overlay (so it isn't drawn
|
|
72
|
+
* twice); the frame then just stays dark / holds the last frame.
|
|
73
|
+
*/
|
|
74
|
+
showStandby?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Override how the stream is sourced (delayed playout, alternate transport).
|
|
77
|
+
* Must be a stable reference passed consistently across renders — it is
|
|
78
|
+
* called as a hook. Omit to use the built-in {@link useKerbcastStream}.
|
|
79
|
+
*/
|
|
80
|
+
useStream?: CameraStreamHook;
|
|
81
|
+
}
|
|
82
|
+
export declare function KerbalFaceFeed({ flightId, size, reportSize, actions, showActions, children, onFeedStateChange, showStandby, useStream, }: KerbalFaceFeedProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* KerbalFaceFeed — the shared single-kerbal-face primitive.
|
|
4
|
+
*
|
|
5
|
+
* Renders ONE kerbal's face-camera stream in a fixed SQUARE frame. It is the
|
|
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
|
|
8
|
+
* `MediaStream` to a persistent `<video>` and NEVER remounts that element on
|
|
9
|
+
* re-layout (the "never remount a live feed" rule — the same srcObject-via-ref
|
|
10
|
+
* technique `CameraFeed` uses).
|
|
11
|
+
*
|
|
12
|
+
* Deliberately minimal chrome: just the square live face plus a standby glyph
|
|
13
|
+
* when there is no live signal. Everything else — name label, IVA/EVA badge,
|
|
14
|
+
* click behaviour, a "SIGNAL LOST" treatment — is layered OVER it by the
|
|
15
|
+
* wrapper via `children`, driven by the `onFeedStateChange` signal. A thin
|
|
16
|
+
* `actions` bar (the shared `FeedAction` shape) is rendered top-right when a
|
|
17
|
+
* wrapper supplies one, so declarative controls compose the same way they do
|
|
18
|
+
* on `CameraFeed`.
|
|
19
|
+
*/
|
|
20
|
+
import { useEffect, useRef } from "react";
|
|
21
|
+
import styled from "styled-components";
|
|
22
|
+
import { FeedActionBar, feedActionToEntry } from "./FeedActionBar";
|
|
23
|
+
import { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
24
|
+
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
25
|
+
import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
26
|
+
import { isCameraDestroyed } from "./lifecycle";
|
|
27
|
+
import { StandbyIcon } from "./StandbyIcon";
|
|
28
|
+
const Frame = styled.div `
|
|
29
|
+
position: relative;
|
|
30
|
+
${(p) => (p.$size != null ? `width: ${p.$size}px; height: ${p.$size}px;` : "width: 100%;")}
|
|
31
|
+
aspect-ratio: 1 / 1;
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
background: #000;
|
|
34
|
+
border-radius: 4px;
|
|
35
|
+
`;
|
|
36
|
+
const Face = styled.video `
|
|
37
|
+
width: 100%;
|
|
38
|
+
height: 100%;
|
|
39
|
+
/* Cover the square: the portrait fills the frame, cropping the excess,
|
|
40
|
+
rather than letterboxing — a face tile reads best edge-to-edge. */
|
|
41
|
+
object-fit: cover;
|
|
42
|
+
display: block;
|
|
43
|
+
`;
|
|
44
|
+
const StandbyLayer = styled.div `
|
|
45
|
+
position: absolute;
|
|
46
|
+
inset: 0;
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: center;
|
|
50
|
+
color: rgba(255, 255, 255, 0.35);
|
|
51
|
+
pointer-events: none;
|
|
52
|
+
`;
|
|
53
|
+
const DestroyedScrim = styled.div `
|
|
54
|
+
position: absolute;
|
|
55
|
+
inset: 0;
|
|
56
|
+
background: rgba(0, 0, 0, 0.45);
|
|
57
|
+
pointer-events: none;
|
|
58
|
+
`;
|
|
59
|
+
const OverlayLayer = styled.div `
|
|
60
|
+
position: absolute;
|
|
61
|
+
inset: 0;
|
|
62
|
+
`;
|
|
63
|
+
/*
|
|
64
|
+
* The shared FeedActionBar is hover-revealed by default (CameraFeed's larger
|
|
65
|
+
* frame fades it in on hover/focus). A tiny face tile has no such affordance,
|
|
66
|
+
* so this override keeps the original always-visible, corner-hugging look;
|
|
67
|
+
* layout/button styling (flex row, gap, the shared OverlayIconButton) still
|
|
68
|
+
* comes from FeedActionBar itself.
|
|
69
|
+
*/
|
|
70
|
+
const KerbalActionBarRow = styled(FeedActionBar) `
|
|
71
|
+
top: 4px;
|
|
72
|
+
right: 4px;
|
|
73
|
+
opacity: 1;
|
|
74
|
+
pointer-events: auto;
|
|
75
|
+
`;
|
|
76
|
+
export function KerbalFaceFeed({ flightId, size, reportSize = true, actions, showActions = true, children, onFeedStateChange, showStandby = true, useStream, }) {
|
|
77
|
+
// Match CameraFeed: the built-in hook (which acquires/releases the
|
|
78
|
+
// subscription slot) runs only when no override is supplied.
|
|
79
|
+
const resolveStream = useStream ?? useKerbcastStream;
|
|
80
|
+
const stream = resolveStream(flightId);
|
|
81
|
+
// The camera's lifecycle comes from the registry snapshot; destroyed cameras
|
|
82
|
+
// linger in the list so the wrapper can show SIGNAL LOST.
|
|
83
|
+
const cameras = useKerbcastCameras();
|
|
84
|
+
const destroyed = cameras.some((c) => c.flightId === flightId && isCameraDestroyed(c));
|
|
85
|
+
const state = destroyed
|
|
86
|
+
? "destroyed"
|
|
87
|
+
: stream
|
|
88
|
+
? "live"
|
|
89
|
+
: "no-signal";
|
|
90
|
+
// Self-measure the square frame and report it to the sidecar (auto-res).
|
|
91
|
+
// Reporting follows the mount lifecycle; the registry collapses multiple
|
|
92
|
+
// feeds of the same kerbal to one MAX report.
|
|
93
|
+
const frameRef = useRef(null);
|
|
94
|
+
useReportDisplaySize(flightId, frameRef, { square: true, enabled: reportSize });
|
|
95
|
+
// Bind the stream to the persistent <video> without remounting it — the
|
|
96
|
+
// element stays mounted across every re-layout / prop change, only its
|
|
97
|
+
// srcObject is (re)assigned. A stall (stream -> null) leaves the last frame.
|
|
98
|
+
const videoRef = useRef(null);
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (videoRef.current && stream) {
|
|
101
|
+
videoRef.current.srcObject = stream;
|
|
102
|
+
}
|
|
103
|
+
}, [stream]);
|
|
104
|
+
// Notify the wrapper on every state transition (and once on mount).
|
|
105
|
+
const notify = useRef(onFeedStateChange);
|
|
106
|
+
notify.current = onFeedStateChange;
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
notify.current?.(state);
|
|
109
|
+
}, [state]);
|
|
110
|
+
return (_jsxs(Frame, { ref: frameRef, "$size": size, "data-testid": "kerbal-face-feed", "data-feed-state": state, children: [_jsx(Face, { ref: videoRef, autoPlay: true, playsInline: true, muted: true, controls: false }), state === "destroyed" && _jsx(DestroyedScrim, {}), showStandby && state !== "live" && (_jsx(StandbyLayer, { children: _jsx(StandbyIcon, { size: Math.round((size ?? 96) * 0.32) }) })), showActions && actions && actions.length > 0 && (_jsx(KerbalActionBarRow, { entries: actions.map(feedActionToEntry) })), children && _jsx(OverlayLayer, { children: children })] }));
|
|
111
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RecordingsTray: a presentational drawer over the client-side recordings
|
|
3
|
+
* store (`useRecordings()`). Lists finished single clips and grouped SETS,
|
|
4
|
+
* with a play/preview, a download, and a discard per clip; a group also gets
|
|
5
|
+
* a download-all + a discard-set. Recording is client-local and in-memory (no
|
|
6
|
+
* sidecar, no server state), so this is where an operator saves a clip before
|
|
7
|
+
* a page reload discards it. The tray shows a quiet reminder of that.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately dumb: it owns no recording logic, only formatting and the
|
|
10
|
+
* per-clip object-URL lifecycle (create on first render, revoke on discard or
|
|
11
|
+
* unmount). Starting/stopping recordings is CameraFeed's REC control (or a
|
|
12
|
+
* host's own REC+ grouped flow); this component only ever reads the store and
|
|
13
|
+
* calls its discard/discardGroup actions.
|
|
14
|
+
*
|
|
15
|
+
* Every `RecordingHandle` (standalone or a grouped member) carries its own
|
|
16
|
+
* `flightId`, so camera names come straight off the clip, with no dependency
|
|
17
|
+
* on the store's transient `active` list. That matters because the tray
|
|
18
|
+
* commonly mounts after a recording has already finished (its `active` entry
|
|
19
|
+
* long gone by then). A host supplies the flightId -> label step via
|
|
20
|
+
* `cameraLabel` (e.g. `buildCameraLabeler(useKerbcastCameras())`, the same
|
|
21
|
+
* labeller CameraFeed uses) so this stays decoupled from any particular
|
|
22
|
+
* camera registry; omit it for a plain "Camera <flightId>" fallback.
|
|
23
|
+
*/
|
|
24
|
+
export interface RecordingsTrayProps {
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a flightId to a display label for a clip's camera name. Omit for
|
|
27
|
+
* a plain "Camera <flightId>" fallback.
|
|
28
|
+
*/
|
|
29
|
+
cameraLabel?: (flightId: number) => string;
|
|
30
|
+
/** Forwarded to the root panel; lets a host lay out/size the tray. */
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The shared drawer. Requires a `KerbcastProvider` ancestor (it calls
|
|
35
|
+
* `useRecordings()` directly, same requirement as `CameraFeed`).
|
|
36
|
+
*/
|
|
37
|
+
export declare function RecordingsTray({ cameraLabel, className }: RecordingsTrayProps): import("react").JSX.Element;
|