@ksp-gonogo/kerbcast-react 1.7.0 → 1.8.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.
- package/CHANGELOG.md +13 -0
- package/dist/CameraFeed.d.ts +32 -16
- package/dist/CameraFeed.js +259 -169
- package/dist/FeedActionBar.d.ts +133 -0
- package/dist/FeedActionBar.js +225 -0
- package/dist/KerbalFaceFeed.d.ts +1 -1
- package/dist/KerbalFaceFeed.js +12 -19
- package/dist/RecordingsTray.d.ts +37 -0
- package/dist/RecordingsTray.js +279 -0
- package/dist/hooks/useRecordings.d.ts +35 -0
- package/dist/hooks/useRecordings.js +33 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +9 -0
- 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 +545 -0
- package/src/CameraFeed.tsx +421 -309
- package/src/FeedActionBar.test.ts +196 -0
- package/src/FeedActionBar.test.tsx +215 -0
- package/src/FeedActionBar.tsx +391 -0
- package/src/KerbalFaceFeed.tsx +13 -35
- package/src/RecordingsTray.test.tsx +288 -0
- package/src/RecordingsTray.tsx +475 -0
- 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/index.ts +15 -0
- package/src/menuPositioning.ts +126 -0
- package/src/timing.test.ts +34 -0
- package/src/timing.ts +21 -0
package/src/CameraFeed.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useId,
|
|
8
8
|
useImperativeHandle,
|
|
9
9
|
useMemo,
|
|
10
|
+
useReducer,
|
|
10
11
|
useRef,
|
|
11
12
|
useState,
|
|
12
13
|
} from "react";
|
|
@@ -14,12 +15,23 @@ import { createPortal } from "react-dom";
|
|
|
14
15
|
import styled, { css } from "styled-components";
|
|
15
16
|
import { buildCameraLabeler } from "./cameraLabels";
|
|
16
17
|
import { KerbcastProvider, useKerbcastClient } from "./context";
|
|
18
|
+
import {
|
|
19
|
+
ActionBarRow,
|
|
20
|
+
FeedActionBar,
|
|
21
|
+
OverlayIconButton,
|
|
22
|
+
feedActionToEntry,
|
|
23
|
+
type FeedAction,
|
|
24
|
+
type FeedActionBarEntry,
|
|
25
|
+
} from "./FeedActionBar";
|
|
17
26
|
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
18
27
|
import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
19
28
|
import { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
29
|
+
import { useRecordings } from "./hooks/useRecordings";
|
|
20
30
|
import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
21
31
|
import { StandbyIcon } from "./StandbyIcon";
|
|
22
32
|
import { isCameraDestroyed } from "./lifecycle";
|
|
33
|
+
import { usePortalMenu } from "./menuPositioning";
|
|
34
|
+
import { formatElapsed, nowMs } from "./timing";
|
|
23
35
|
|
|
24
36
|
// ---------------------------------------------------------------------------
|
|
25
37
|
// Tuning constants
|
|
@@ -29,124 +41,6 @@ const MENU_MAX_WIDTH = 260; // matches CameraMenu's CSS cap
|
|
|
29
41
|
const MENU_MAX_HEIGHT = 300; // matches CameraMenu's min(40vh, 300px) cap
|
|
30
42
|
const QUALITY_MENU_MAX_WIDTH = 220; // matches QualityMenu's CSS cap
|
|
31
43
|
const QUALITY_MENU_MAX_HEIGHT = 220; // matches QualityMenu's min(40vh, 220px) cap
|
|
32
|
-
const MENU_GAP = 4; // trigger-to-menu spacing
|
|
33
|
-
const MENU_EDGE = 8; // minimum inset from the viewport edge
|
|
34
|
-
|
|
35
|
-
/*
|
|
36
|
-
* Geometry of a portaled dropdown: the CSS size caps of the menu (the
|
|
37
|
-
* helper clamps as if the menu fills them) and which edge of the trigger
|
|
38
|
-
* the menu hangs from: "start" lines the menu's left edge up with the
|
|
39
|
-
* trigger's (camera picker), "end" lines the right edges up (quality
|
|
40
|
-
* button at the action bar's corner).
|
|
41
|
-
*/
|
|
42
|
-
interface MenuAnchor {
|
|
43
|
-
maxWidth: number;
|
|
44
|
-
maxHeight: number;
|
|
45
|
-
align: "start" | "end";
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/*
|
|
49
|
-
* Fixed-position style for a portaled menu, anchored to its trigger button.
|
|
50
|
-
* Opens downward by default; flips above the trigger when there is not
|
|
51
|
-
* enough room below but there is above, otherwise clamps to the viewport.
|
|
52
|
-
*/
|
|
53
|
-
function computeMenuPosition(
|
|
54
|
-
trigger: HTMLElement,
|
|
55
|
-
anchor: MenuAnchor,
|
|
56
|
-
): React.CSSProperties {
|
|
57
|
-
const rect = trigger.getBoundingClientRect();
|
|
58
|
-
const vw = window.innerWidth;
|
|
59
|
-
const vh = window.innerHeight;
|
|
60
|
-
const width = Math.min(anchor.maxWidth, vw - 2 * MENU_EDGE);
|
|
61
|
-
/* Inline offset from the aligned viewport edge, clamped so a full-width
|
|
62
|
-
menu still fits inside the opposite edge. */
|
|
63
|
-
const inset = Math.max(MENU_EDGE, vw - width - MENU_EDGE);
|
|
64
|
-
const inline: React.CSSProperties =
|
|
65
|
-
anchor.align === "start"
|
|
66
|
-
? { left: Math.min(Math.max(rect.left, MENU_EDGE), inset) }
|
|
67
|
-
: { right: Math.min(Math.max(vw - rect.right, MENU_EDGE), inset) };
|
|
68
|
-
const menuH = Math.min(0.4 * vh, anchor.maxHeight);
|
|
69
|
-
const fitsBelow = rect.bottom + MENU_GAP + menuH <= vh - MENU_EDGE;
|
|
70
|
-
const fitsAbove = rect.top - MENU_GAP - menuH >= MENU_EDGE;
|
|
71
|
-
if (!fitsBelow && fitsAbove) {
|
|
72
|
-
return { ...inline, bottom: vh - rect.top + MENU_GAP };
|
|
73
|
-
}
|
|
74
|
-
const top = Math.min(
|
|
75
|
-
rect.bottom + MENU_GAP,
|
|
76
|
-
Math.max(MENU_EDGE, vh - MENU_EDGE - menuH),
|
|
77
|
-
);
|
|
78
|
-
return { ...inline, top };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/*
|
|
82
|
-
* Shared behaviour for a dropdown portaled to document.body: fixed position
|
|
83
|
-
* computed from the trigger's rect (so tile overflow cannot clip it),
|
|
84
|
-
* re-anchored on window resize/scroll while open, Escape to close with focus
|
|
85
|
-
* returned to the trigger, and portal-aware outside-pointer-down dismissal
|
|
86
|
-
* (menuRef points at the portaled menu, so clicks inside it stay "inside").
|
|
87
|
-
* One menu at a time falls out of the dismissal: opening another menu's
|
|
88
|
-
* trigger is an outside press for this one.
|
|
89
|
-
*/
|
|
90
|
-
function usePortalMenu({ maxWidth, maxHeight, align }: MenuAnchor) {
|
|
91
|
-
const [open, setOpen] = useState(false);
|
|
92
|
-
const [position, setPosition] = useState<React.CSSProperties | null>(null);
|
|
93
|
-
const menuRef = useRef<HTMLDivElement>(null);
|
|
94
|
-
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
95
|
-
|
|
96
|
-
useEffect(() => {
|
|
97
|
-
if (!open) {
|
|
98
|
-
setPosition(null);
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
const update = () => {
|
|
102
|
-
const trigger = triggerRef.current;
|
|
103
|
-
if (trigger) {
|
|
104
|
-
setPosition(computeMenuPosition(trigger, { maxWidth, maxHeight, align }));
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
update();
|
|
108
|
-
window.addEventListener("resize", update);
|
|
109
|
-
window.addEventListener("scroll", update, true);
|
|
110
|
-
return () => {
|
|
111
|
-
window.removeEventListener("resize", update);
|
|
112
|
-
window.removeEventListener("scroll", update, true);
|
|
113
|
-
};
|
|
114
|
-
}, [open, maxWidth, maxHeight, align]);
|
|
115
|
-
|
|
116
|
-
useEffect(() => {
|
|
117
|
-
if (!open) return;
|
|
118
|
-
const onKeyDown = (e: KeyboardEvent) => {
|
|
119
|
-
if (e.key === "Escape") {
|
|
120
|
-
e.stopPropagation();
|
|
121
|
-
setOpen(false);
|
|
122
|
-
triggerRef.current?.focus();
|
|
123
|
-
}
|
|
124
|
-
};
|
|
125
|
-
const onPointerDown = (e: PointerEvent) => {
|
|
126
|
-
if (
|
|
127
|
-
!menuRef.current?.contains(e.target as Node) &&
|
|
128
|
-
!triggerRef.current?.contains(e.target as Node)
|
|
129
|
-
) {
|
|
130
|
-
setOpen(false);
|
|
131
|
-
}
|
|
132
|
-
};
|
|
133
|
-
document.addEventListener("keydown", onKeyDown);
|
|
134
|
-
document.addEventListener("pointerdown", onPointerDown);
|
|
135
|
-
return () => {
|
|
136
|
-
document.removeEventListener("keydown", onKeyDown);
|
|
137
|
-
document.removeEventListener("pointerdown", onPointerDown);
|
|
138
|
-
};
|
|
139
|
-
}, [open]);
|
|
140
|
-
|
|
141
|
-
const toggle = useCallback(() => setOpen((v) => !v), []);
|
|
142
|
-
/** Close after an item pick, returning focus to the trigger. */
|
|
143
|
-
const close = useCallback(() => {
|
|
144
|
-
setOpen(false);
|
|
145
|
-
triggerRef.current?.focus();
|
|
146
|
-
}, []);
|
|
147
|
-
|
|
148
|
-
return { open, toggle, close, position, menuRef, triggerRef };
|
|
149
|
-
}
|
|
150
44
|
|
|
151
45
|
/*
|
|
152
46
|
* Viewer quality presets, in menu order. Scales mirror the sidecar's
|
|
@@ -209,21 +103,11 @@ function exitFullscreen(): void {
|
|
|
209
103
|
// ---------------------------------------------------------------------------
|
|
210
104
|
|
|
211
105
|
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
106
|
+
* The consumer-supplied action shape, and the shared top-right action bar
|
|
107
|
+
* both `CameraFeed` and `KerbalFaceFeed` render, live in `FeedActionBar`.
|
|
108
|
+
* Re-exported here since existing imports reference `./CameraFeed`.
|
|
215
109
|
*/
|
|
216
|
-
export
|
|
217
|
-
/** Stable identity for the React key. */
|
|
218
|
-
id: string;
|
|
219
|
-
/** Accessible label; used for aria-label and the native tooltip. */
|
|
220
|
-
label: string;
|
|
221
|
-
/** Icon node, sized by the action bar (~14px). */
|
|
222
|
-
icon: React.ReactNode;
|
|
223
|
-
/** Toggle state: renders the button highlighted and sets aria-pressed. */
|
|
224
|
-
active?: boolean;
|
|
225
|
-
onClick: () => void;
|
|
226
|
-
}
|
|
110
|
+
export type { FeedAction } from "./FeedActionBar";
|
|
227
111
|
|
|
228
112
|
/**
|
|
229
113
|
* A hook that yields the live `MediaStream` for a resolved flightId. Must be
|
|
@@ -336,6 +220,29 @@ export interface CameraFeedProps {
|
|
|
336
220
|
* the gimbal + FoV). Default false.
|
|
337
221
|
*/
|
|
338
222
|
enableTracking?: boolean;
|
|
223
|
+
/**
|
|
224
|
+
* Show a built-in REC control in the action bar: a STATEFUL toggle (never
|
|
225
|
+
* overflow-eligible, per the #6 spec) that starts/stops a client-side
|
|
226
|
+
* recording of this feed via `useRecordings()`. Idle -> click starts
|
|
227
|
+
* (`start(flightId)`); active shows a red dot + a live elapsed timer on the
|
|
228
|
+
* tile -> click stops (`stop(recordingId)`) and the clip lands in the
|
|
229
|
+
* recordings store. Recording is purely client-local (this browser's own
|
|
230
|
+
* MediaRecorder, nothing server-authoritative) and read from the store
|
|
231
|
+
* (`isRecording(flightId)`), so it stays correct if toggled elsewhere (e.g.
|
|
232
|
+
* a REC+ grouped recording). Default false.
|
|
233
|
+
*/
|
|
234
|
+
enableRecording?: boolean;
|
|
235
|
+
/**
|
|
236
|
+
* Record this feed at the operator's full render size for the duration of
|
|
237
|
+
* the built-in REC control's recording (overrides only the display-size
|
|
238
|
+
* demand; still yields to the adaptive framerate shed). Off by default.
|
|
239
|
+
* Threaded straight into `start(flightId, { forceFullResolution })`; has
|
|
240
|
+
* no effect unless `enableRecording` is also set. While the feed is armed
|
|
241
|
+
* (force sent, waiting for the resolution bump) the tile shows an
|
|
242
|
+
* "ARMING" pill under the title in place of the REC badge, then switches
|
|
243
|
+
* to the normal REC badge + elapsed timer once recording actually starts.
|
|
244
|
+
*/
|
|
245
|
+
recordFullResolution?: boolean;
|
|
339
246
|
/**
|
|
340
247
|
* Consumer-injected action buttons, rendered left of the built-in
|
|
341
248
|
* fullscreen/PiP controls in the top-right action bar.
|
|
@@ -343,8 +250,10 @@ export interface CameraFeedProps {
|
|
|
343
250
|
actions?: FeedAction[];
|
|
344
251
|
/**
|
|
345
252
|
* Consumer-injected action buttons rendered at the far end of the action
|
|
346
|
-
* bar
|
|
347
|
-
*
|
|
253
|
+
* bar: the natural home for a close/remove button. Always pinned trailing
|
|
254
|
+
* (never overflow-eligible, never counted toward the overflow threshold),
|
|
255
|
+
* so it sits in the corner as a persistent, single-click control, right of
|
|
256
|
+
* the ⋮ overflow trigger when one is present.
|
|
348
257
|
*/
|
|
349
258
|
trailingActions?: FeedAction[];
|
|
350
259
|
/**
|
|
@@ -393,6 +302,8 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
393
302
|
enablePictureInPicture = false,
|
|
394
303
|
enableQualityControl = false,
|
|
395
304
|
enableTracking = false,
|
|
305
|
+
enableRecording = false,
|
|
306
|
+
recordFullResolution = false,
|
|
396
307
|
actions,
|
|
397
308
|
trailingActions,
|
|
398
309
|
showActions = true,
|
|
@@ -401,6 +312,9 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
401
312
|
ref,
|
|
402
313
|
) {
|
|
403
314
|
const client = useKerbcastClient();
|
|
315
|
+
// Always called (rules of hooks); `enableRecording` only gates whether the
|
|
316
|
+
// REC entry/badge render, not whether the store is subscribed.
|
|
317
|
+
const recordings = useRecordings();
|
|
404
318
|
// The selectable set: every live camera, optionally narrowed by the host's
|
|
405
319
|
// cameraFilter (e.g. a part-grid tile offering only part cams). Drives the
|
|
406
320
|
// picker menu, the stepper, and the auto-latch fallback so none can land on
|
|
@@ -619,7 +533,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
619
533
|
);
|
|
620
534
|
|
|
621
535
|
// -------------------------------------------------------------------------
|
|
622
|
-
// PanZoomController
|
|
536
|
+
// PanZoomController: one per displayed camera
|
|
623
537
|
// -------------------------------------------------------------------------
|
|
624
538
|
const controllerRef = useRef<PanZoomController | null>(null);
|
|
625
539
|
const [sliderFov, setSliderFov] = useState(60);
|
|
@@ -636,8 +550,8 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
636
550
|
controllerRef.current = ctrl;
|
|
637
551
|
|
|
638
552
|
const unsubSlider = ctrl.onSliderFov(setSliderFov);
|
|
639
|
-
// Seed the slider from the controller's initial (0)
|
|
640
|
-
// immediately by syncFromState in the camera-state effect below.
|
|
553
|
+
// Seed the slider from the controller's initial (0). It will be
|
|
554
|
+
// overwritten immediately by syncFromState in the camera-state effect below.
|
|
641
555
|
setSliderFov(ctrl.sliderFov);
|
|
642
556
|
|
|
643
557
|
return () => {
|
|
@@ -828,6 +742,55 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
828
742
|
[client, flightId, trackMode, closeTrackingMenu],
|
|
829
743
|
);
|
|
830
744
|
|
|
745
|
+
// -------------------------------------------------------------------------
|
|
746
|
+
// REC control (opt-in). A stateful toggle that starts/stops a client-side
|
|
747
|
+
// recording of this feed via the recordings store; state is read from the
|
|
748
|
+
// store (isRecording), never held locally, so it stays correct if the
|
|
749
|
+
// recording was started elsewhere (e.g. a REC+ grouped recording).
|
|
750
|
+
// -------------------------------------------------------------------------
|
|
751
|
+
const recordingActive =
|
|
752
|
+
enableRecording && flightId !== null && recordings.isRecording(flightId);
|
|
753
|
+
const activeRecording = recordingActive
|
|
754
|
+
? recordings.active.find((a) => a.flightId === flightId)
|
|
755
|
+
: undefined;
|
|
756
|
+
/* True while a single forced recording is armed (force sent, waiting for
|
|
757
|
+
the feed to reach full resolution) but the recorder has not started
|
|
758
|
+
yet (see ActiveRecordingInfo.arming). Drives the ARMING pill below in
|
|
759
|
+
place of the REC badge. */
|
|
760
|
+
const armingActive = activeRecording?.arming === true;
|
|
761
|
+
|
|
762
|
+
// Ticks once a second while recording so the elapsed timer stays live;
|
|
763
|
+
// the timer itself is derived from activeRecording.startedAt each render.
|
|
764
|
+
const [, tickRecordingTimer] = useReducer((c: number) => c + 1, 0);
|
|
765
|
+
useEffect(() => {
|
|
766
|
+
if (!recordingActive) return;
|
|
767
|
+
const interval = setInterval(tickRecordingTimer, 1000);
|
|
768
|
+
return () => clearInterval(interval);
|
|
769
|
+
}, [recordingActive]);
|
|
770
|
+
|
|
771
|
+
const recordingElapsedMs = activeRecording ? nowMs() - activeRecording.startedAt : 0;
|
|
772
|
+
|
|
773
|
+
const toggleRecording = useCallback(() => {
|
|
774
|
+
if (flightId === null) return;
|
|
775
|
+
if (recordingActive) {
|
|
776
|
+
const active = recordings.active.find((a) => a.flightId === flightId);
|
|
777
|
+
if (active) {
|
|
778
|
+
void recordings.stop(active.recordingId).catch(() => {
|
|
779
|
+
/* A rejected stop shouldn't leave the REC toggle wedged; the
|
|
780
|
+
store's own state (active/recordings) is the source of truth
|
|
781
|
+
for whether it's still "recording", not this call's outcome. */
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
try {
|
|
787
|
+
recordings.start(flightId, { forceFullResolution: recordFullResolution });
|
|
788
|
+
} catch {
|
|
789
|
+
/* no live track yet, or a race with a recording already in progress
|
|
790
|
+
elsewhere on this feed; the operator can retry once live. */
|
|
791
|
+
}
|
|
792
|
+
}, [flightId, recordingActive, recordings, recordFullResolution]);
|
|
793
|
+
|
|
831
794
|
const topOverlay = (
|
|
832
795
|
<TopOverlay>
|
|
833
796
|
<TitleRow>
|
|
@@ -839,7 +802,9 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
839
802
|
aria-haspopup="menu"
|
|
840
803
|
aria-expanded={cameraMenu.open}
|
|
841
804
|
aria-controls={menuId}
|
|
842
|
-
|
|
805
|
+
disabled={recordingActive}
|
|
806
|
+
title={recordingActive ? "Stop recording to change camera" : undefined}
|
|
807
|
+
onClick={recordingActive ? undefined : cameraMenu.toggle}
|
|
843
808
|
>
|
|
844
809
|
<TitleButton__Text>{title}</TitleButton__Text>
|
|
845
810
|
<ChevronDownIcon aria-hidden="true" />
|
|
@@ -894,121 +859,205 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
894
859
|
</TopOverlay>
|
|
895
860
|
);
|
|
896
861
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
862
|
+
/*
|
|
863
|
+
* Built-in action-bar entries, in the bar's natural (fallback) order:
|
|
864
|
+
* consumer actions, quality, tracking, PiP, fullscreen, trailing actions.
|
|
865
|
+
* FeedActionBar partitions these into primary/overflow (see its own
|
|
866
|
+
* doc-comment); tracking is the only built-in marked `stateful` today,
|
|
867
|
+
* per #6's "stateful toggles stay primary" rule (REC joins it the same
|
|
868
|
+
* way).
|
|
869
|
+
*/
|
|
870
|
+
const trackingLabel = tracking
|
|
871
|
+
? trackMode === TrackMode.Target
|
|
872
|
+
? "Auto-tracking target"
|
|
873
|
+
: "Auto-tracking active vessel"
|
|
874
|
+
: "Auto-track a vessel";
|
|
875
|
+
const pipLabel = isPip ? "Exit picture in picture" : "Picture in picture";
|
|
876
|
+
const fullscreenLabel = isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
|
|
877
|
+
const recordingLabel = recordingActive ? "Stop recording" : "Start recording";
|
|
878
|
+
|
|
879
|
+
const entries: FeedActionBarEntry[] = [
|
|
880
|
+
...(actions?.map(feedActionToEntry) ?? []),
|
|
881
|
+
...(qualityAvailable
|
|
882
|
+
? [
|
|
883
|
+
{
|
|
884
|
+
id: "quality",
|
|
885
|
+
label: "Quality",
|
|
886
|
+
stateful: false,
|
|
887
|
+
render: () => (
|
|
888
|
+
<OverlayIconButton
|
|
889
|
+
ref={qualityMenu.triggerRef}
|
|
890
|
+
type="button"
|
|
891
|
+
aria-label="Quality"
|
|
892
|
+
aria-haspopup="menu"
|
|
893
|
+
aria-expanded={qualityMenu.open}
|
|
894
|
+
aria-controls={qualityMenuId}
|
|
895
|
+
title={
|
|
896
|
+
qualityThrottled
|
|
897
|
+
? "Quality (throttled by adaptive performance)"
|
|
898
|
+
: "Quality"
|
|
899
|
+
}
|
|
900
|
+
$active={qualityMenu.open}
|
|
901
|
+
onClick={qualityMenu.toggle}
|
|
902
|
+
>
|
|
903
|
+
<QualityIcon />
|
|
904
|
+
{qualityThrottled && <ThrottledDot aria-hidden="true" />}
|
|
905
|
+
</OverlayIconButton>
|
|
906
|
+
),
|
|
907
|
+
} satisfies FeedActionBarEntry,
|
|
908
|
+
]
|
|
909
|
+
: []),
|
|
910
|
+
...(enableRecording && flightId !== null
|
|
911
|
+
? [
|
|
912
|
+
{
|
|
913
|
+
id: "record",
|
|
914
|
+
label: recordingLabel,
|
|
915
|
+
stateful: true,
|
|
916
|
+
render: () => (
|
|
917
|
+
<RecordActionButton
|
|
918
|
+
type="button"
|
|
919
|
+
aria-label={recordingLabel}
|
|
920
|
+
aria-pressed={recordingActive}
|
|
921
|
+
title={recordingLabel}
|
|
922
|
+
$active={recordingActive}
|
|
923
|
+
onClick={toggleRecording}
|
|
924
|
+
>
|
|
925
|
+
<RecordIcon />
|
|
926
|
+
</RecordActionButton>
|
|
927
|
+
),
|
|
928
|
+
} satisfies FeedActionBarEntry,
|
|
929
|
+
]
|
|
930
|
+
: []),
|
|
931
|
+
...(trackingAvailable
|
|
932
|
+
? [
|
|
933
|
+
{
|
|
934
|
+
id: "tracking",
|
|
935
|
+
label: trackingLabel,
|
|
936
|
+
stateful: true,
|
|
937
|
+
render: () => (
|
|
938
|
+
<OverlayIconButton
|
|
939
|
+
ref={trackingMenu.triggerRef}
|
|
940
|
+
type="button"
|
|
941
|
+
aria-label="Auto-track"
|
|
942
|
+
aria-haspopup="menu"
|
|
943
|
+
aria-expanded={trackingMenu.open}
|
|
944
|
+
aria-controls={trackingMenuId}
|
|
945
|
+
aria-pressed={tracking}
|
|
946
|
+
title={trackingLabel}
|
|
947
|
+
$active={tracking || trackingMenu.open}
|
|
948
|
+
onClick={trackingMenu.toggle}
|
|
949
|
+
>
|
|
950
|
+
<CrosshairIcon />
|
|
951
|
+
</OverlayIconButton>
|
|
952
|
+
),
|
|
953
|
+
} satisfies FeedActionBarEntry,
|
|
954
|
+
]
|
|
955
|
+
: []),
|
|
956
|
+
...(flightId !== null && pipAvailable
|
|
957
|
+
? [
|
|
958
|
+
{
|
|
959
|
+
id: "pip",
|
|
960
|
+
label: pipLabel,
|
|
961
|
+
stateful: false,
|
|
962
|
+
render: () => (
|
|
963
|
+
<OverlayIconButton
|
|
964
|
+
type="button"
|
|
965
|
+
aria-label={pipLabel}
|
|
966
|
+
aria-pressed={isPip}
|
|
967
|
+
title={pipLabel}
|
|
968
|
+
$active={isPip}
|
|
969
|
+
onClick={togglePip}
|
|
970
|
+
>
|
|
971
|
+
<PictureInPictureIcon />
|
|
972
|
+
</OverlayIconButton>
|
|
973
|
+
),
|
|
974
|
+
} satisfies FeedActionBarEntry,
|
|
975
|
+
]
|
|
976
|
+
: []),
|
|
977
|
+
...(flightId !== null && fullscreenAvailable
|
|
978
|
+
? [
|
|
979
|
+
{
|
|
980
|
+
id: "fullscreen",
|
|
981
|
+
label: fullscreenLabel,
|
|
982
|
+
stateful: false,
|
|
983
|
+
render: () => (
|
|
984
|
+
<OverlayIconButton
|
|
985
|
+
type="button"
|
|
986
|
+
aria-label={fullscreenLabel}
|
|
987
|
+
aria-pressed={isFullscreen}
|
|
988
|
+
title={fullscreenLabel}
|
|
989
|
+
$active={isFullscreen}
|
|
990
|
+
onClick={toggleFullscreen}
|
|
991
|
+
>
|
|
992
|
+
{isFullscreen ? <FullscreenExitIcon /> : <FullscreenEnterIcon />}
|
|
993
|
+
</OverlayIconButton>
|
|
994
|
+
),
|
|
995
|
+
} satisfies FeedActionBarEntry,
|
|
996
|
+
]
|
|
997
|
+
: []),
|
|
998
|
+
/* trailingActions is always the pinned-trailing (close/remove) slot,
|
|
999
|
+
regardless of what the consumer's FeedAction sets. See its own
|
|
1000
|
+
doc-comment on CameraFeedProps. While a recording is active, closing
|
|
1001
|
+
the feed would lose it, so the control stays visible but disabled
|
|
1002
|
+
and inert rather than disappearing. */
|
|
1003
|
+
...(trailingActions?.map((a) => {
|
|
1004
|
+
const entry = feedActionToEntry(a);
|
|
1005
|
+
if (!recordingActive) return { ...entry, pinnedTrailing: true };
|
|
1006
|
+
return {
|
|
1007
|
+
...entry,
|
|
1008
|
+
pinnedTrailing: true,
|
|
1009
|
+
render: () => (
|
|
1010
|
+
<OverlayIconButton
|
|
1011
|
+
key={a.id}
|
|
1012
|
+
type="button"
|
|
1013
|
+
aria-label={a.label}
|
|
1014
|
+
title="Stop recording to close this feed"
|
|
1015
|
+
disabled
|
|
1016
|
+
$active={a.active ?? false}
|
|
1017
|
+
>
|
|
1018
|
+
{a.icon}
|
|
1019
|
+
</OverlayIconButton>
|
|
1020
|
+
),
|
|
1021
|
+
};
|
|
1022
|
+
}) ?? []),
|
|
1023
|
+
];
|
|
910
1024
|
|
|
911
1025
|
const builtInActions =
|
|
912
1026
|
flightId !== null &&
|
|
913
|
-
(pipAvailable ||
|
|
1027
|
+
(pipAvailable ||
|
|
1028
|
+
fullscreenAvailable ||
|
|
1029
|
+
qualityAvailable ||
|
|
1030
|
+
trackingAvailable ||
|
|
1031
|
+
enableRecording);
|
|
914
1032
|
const hasActionBar =
|
|
915
1033
|
showActions &&
|
|
916
1034
|
(hasCameras ||
|
|
917
1035
|
(actions && actions.length > 0) ||
|
|
918
1036
|
(trailingActions && trailingActions.length > 0) ||
|
|
919
1037
|
builtInActions);
|
|
1038
|
+
const stepButtons = hasCameras ? (
|
|
1039
|
+
<StepButtons>
|
|
1040
|
+
<OverlayIconButton
|
|
1041
|
+
type="button"
|
|
1042
|
+
aria-label="Previous camera"
|
|
1043
|
+
disabled={!canStep}
|
|
1044
|
+
onClick={() => stepCamera(-1)}
|
|
1045
|
+
>
|
|
1046
|
+
‹
|
|
1047
|
+
</OverlayIconButton>
|
|
1048
|
+
<OverlayIconButton
|
|
1049
|
+
type="button"
|
|
1050
|
+
aria-label="Next camera"
|
|
1051
|
+
disabled={!canStep}
|
|
1052
|
+
onClick={() => stepCamera(1)}
|
|
1053
|
+
>
|
|
1054
|
+
›
|
|
1055
|
+
</OverlayIconButton>
|
|
1056
|
+
</StepButtons>
|
|
1057
|
+
) : null;
|
|
920
1058
|
const actionBar = hasActionBar ? (
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
<StepButtons>
|
|
924
|
-
<OverlayIconButton
|
|
925
|
-
type="button"
|
|
926
|
-
aria-label="Previous camera"
|
|
927
|
-
disabled={!canStep}
|
|
928
|
-
onClick={() => stepCamera(-1)}
|
|
929
|
-
>
|
|
930
|
-
‹
|
|
931
|
-
</OverlayIconButton>
|
|
932
|
-
<OverlayIconButton
|
|
933
|
-
type="button"
|
|
934
|
-
aria-label="Next camera"
|
|
935
|
-
disabled={!canStep}
|
|
936
|
-
onClick={() => stepCamera(1)}
|
|
937
|
-
>
|
|
938
|
-
›
|
|
939
|
-
</OverlayIconButton>
|
|
940
|
-
</StepButtons>
|
|
941
|
-
)}
|
|
942
|
-
{actions?.map(renderAction)}
|
|
943
|
-
{qualityAvailable && (
|
|
944
|
-
<OverlayIconButton
|
|
945
|
-
ref={qualityMenu.triggerRef}
|
|
946
|
-
type="button"
|
|
947
|
-
aria-label="Quality"
|
|
948
|
-
aria-haspopup="menu"
|
|
949
|
-
aria-expanded={qualityMenu.open}
|
|
950
|
-
aria-controls={qualityMenuId}
|
|
951
|
-
title={
|
|
952
|
-
qualityThrottled
|
|
953
|
-
? "Quality (throttled by adaptive performance)"
|
|
954
|
-
: "Quality"
|
|
955
|
-
}
|
|
956
|
-
$active={qualityMenu.open}
|
|
957
|
-
onClick={qualityMenu.toggle}
|
|
958
|
-
>
|
|
959
|
-
<QualityIcon />
|
|
960
|
-
{qualityThrottled && <ThrottledDot aria-hidden="true" />}
|
|
961
|
-
</OverlayIconButton>
|
|
962
|
-
)}
|
|
963
|
-
{trackingAvailable && (
|
|
964
|
-
<OverlayIconButton
|
|
965
|
-
ref={trackingMenu.triggerRef}
|
|
966
|
-
type="button"
|
|
967
|
-
aria-label="Auto-track"
|
|
968
|
-
aria-haspopup="menu"
|
|
969
|
-
aria-expanded={trackingMenu.open}
|
|
970
|
-
aria-controls={trackingMenuId}
|
|
971
|
-
aria-pressed={tracking}
|
|
972
|
-
title={
|
|
973
|
-
tracking
|
|
974
|
-
? trackMode === TrackMode.Target
|
|
975
|
-
? "Auto-tracking target"
|
|
976
|
-
: "Auto-tracking active vessel"
|
|
977
|
-
: "Auto-track a vessel"
|
|
978
|
-
}
|
|
979
|
-
$active={tracking || trackingMenu.open}
|
|
980
|
-
onClick={trackingMenu.toggle}
|
|
981
|
-
>
|
|
982
|
-
<CrosshairIcon />
|
|
983
|
-
</OverlayIconButton>
|
|
984
|
-
)}
|
|
985
|
-
{flightId !== null && pipAvailable && (
|
|
986
|
-
<OverlayIconButton
|
|
987
|
-
type="button"
|
|
988
|
-
aria-label={isPip ? "Exit picture in picture" : "Picture in picture"}
|
|
989
|
-
aria-pressed={isPip}
|
|
990
|
-
title={isPip ? "Exit picture in picture" : "Picture in picture"}
|
|
991
|
-
$active={isPip}
|
|
992
|
-
onClick={togglePip}
|
|
993
|
-
>
|
|
994
|
-
<PictureInPictureIcon />
|
|
995
|
-
</OverlayIconButton>
|
|
996
|
-
)}
|
|
997
|
-
{flightId !== null && fullscreenAvailable && (
|
|
998
|
-
<OverlayIconButton
|
|
999
|
-
type="button"
|
|
1000
|
-
aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
|
1001
|
-
aria-pressed={isFullscreen}
|
|
1002
|
-
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
|
1003
|
-
$active={isFullscreen}
|
|
1004
|
-
onClick={toggleFullscreen}
|
|
1005
|
-
>
|
|
1006
|
-
{isFullscreen ? <FullscreenExitIcon /> : <FullscreenEnterIcon />}
|
|
1007
|
-
</OverlayIconButton>
|
|
1008
|
-
)}
|
|
1009
|
-
{trailingActions?.map(renderAction)}
|
|
1010
|
-
</ActionBar>
|
|
1011
|
-
) : null;
|
|
1059
|
+
<FeedActionBar leading={stepButtons} entries={entries} />
|
|
1060
|
+
) : null;
|
|
1012
1061
|
|
|
1013
1062
|
return (
|
|
1014
1063
|
<Stage ref={wrapRef} $pinned={chromePinned}>
|
|
@@ -1130,6 +1179,18 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
|
|
|
1130
1179
|
</StaleBadge>
|
|
1131
1180
|
</>
|
|
1132
1181
|
)}
|
|
1182
|
+
{recordingActive && armingActive && (
|
|
1183
|
+
<ArmingBadge role="status" aria-label="Arming">
|
|
1184
|
+
<ArmingDot aria-hidden="true" />
|
|
1185
|
+
ARMING
|
|
1186
|
+
</ArmingBadge>
|
|
1187
|
+
)}
|
|
1188
|
+
{recordingActive && !armingActive && (
|
|
1189
|
+
<RecBadge role="status" aria-label="Recording">
|
|
1190
|
+
<RecDot aria-hidden="true" />
|
|
1191
|
+
REC {formatElapsed(recordingElapsedMs)}
|
|
1192
|
+
</RecBadge>
|
|
1193
|
+
)}
|
|
1133
1194
|
{showZoom && (
|
|
1134
1195
|
<ZoomControlsWrap $disabled={manualDisabled} aria-hidden={manualDisabled}>
|
|
1135
1196
|
<ZoomButton
|
|
@@ -1367,6 +1428,15 @@ function PictureInPictureIcon() {
|
|
|
1367
1428
|
);
|
|
1368
1429
|
}
|
|
1369
1430
|
|
|
1431
|
+
/* REC: a plain filled dot, coloured by RecordActionButton's own $active state. */
|
|
1432
|
+
function RecordIcon() {
|
|
1433
|
+
return (
|
|
1434
|
+
<svg viewBox="0 0 16 16" width={14} height={14} fill="currentColor" stroke="none" aria-hidden="true">
|
|
1435
|
+
<circle cx="8" cy="8" r="5" />
|
|
1436
|
+
</svg>
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1370
1440
|
// ---------------------------------------------------------------------------
|
|
1371
1441
|
// Styled components
|
|
1372
1442
|
// ---------------------------------------------------------------------------
|
|
@@ -1695,21 +1765,114 @@ const ThrottledDot = styled.span`
|
|
|
1695
1765
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6);
|
|
1696
1766
|
`;
|
|
1697
1767
|
|
|
1698
|
-
/*
|
|
1699
|
-
|
|
1768
|
+
/* REC action-bar button: same shape as OverlayIconButton, its own red
|
|
1769
|
+
$active colour (a coming-recording toggle should never read as the
|
|
1770
|
+
green "on" of tracking/quality) via its own token so a consumer can
|
|
1771
|
+
recolour it without touching --kerbcast-action-active. */
|
|
1772
|
+
const RecordActionButton = styled(OverlayIconButton)<{ $active?: boolean }>`
|
|
1773
|
+
background: ${(p) =>
|
|
1774
|
+
p.$active ? "var(--kerbcast-rec-active, #ff3b30)" : "rgba(0, 0, 0, 0.5)"};
|
|
1775
|
+
border-color: ${(p) =>
|
|
1776
|
+
p.$active ? "var(--kerbcast-rec-active, #ff3b30)" : "rgba(255, 255, 255, 0.3)"};
|
|
1777
|
+
color: #fff;
|
|
1778
|
+
|
|
1779
|
+
@media (hover: hover) {
|
|
1780
|
+
&:hover {
|
|
1781
|
+
background: ${(p) =>
|
|
1782
|
+
p.$active ? "var(--kerbcast-rec-active, #ff3b30)" : "rgba(0, 0, 0, 0.7)"};
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
`;
|
|
1786
|
+
|
|
1787
|
+
/* Persistent recording badge: red dot + elapsed timer, always visible while
|
|
1788
|
+
recording (not hover chrome), same convention as StaleBadge. */
|
|
1789
|
+
const RecBadge = styled.div`
|
|
1700
1790
|
position: absolute;
|
|
1701
|
-
top
|
|
1702
|
-
|
|
1703
|
-
|
|
1791
|
+
/* Sits just under the hover-gated title/source dropdown (both are top-left)
|
|
1792
|
+
so an active recording stays visible without displacing that control. */
|
|
1793
|
+
top: 28px;
|
|
1794
|
+
left: 8px;
|
|
1795
|
+
z-index: 2;
|
|
1704
1796
|
display: flex;
|
|
1705
1797
|
align-items: center;
|
|
1706
|
-
gap:
|
|
1707
|
-
|
|
1798
|
+
gap: 5px;
|
|
1799
|
+
padding: 2px 6px;
|
|
1800
|
+
background: rgba(0, 0, 0, 0.6);
|
|
1801
|
+
border: 1px solid var(--kerbcast-rec-active, #ff3b30);
|
|
1802
|
+
border-radius: 3px;
|
|
1803
|
+
color: #fff;
|
|
1804
|
+
font-size: 10px;
|
|
1805
|
+
font-weight: 600;
|
|
1806
|
+
letter-spacing: 0.12em;
|
|
1807
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
1708
1808
|
pointer-events: none;
|
|
1709
|
-
|
|
1809
|
+
`;
|
|
1710
1810
|
|
|
1711
|
-
|
|
1712
|
-
|
|
1811
|
+
const RecDot = styled.span`
|
|
1812
|
+
width: 7px;
|
|
1813
|
+
height: 7px;
|
|
1814
|
+
border-radius: 50%;
|
|
1815
|
+
background: var(--kerbcast-rec-active, #ff3b30);
|
|
1816
|
+
flex-shrink: 0;
|
|
1817
|
+
|
|
1818
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
1819
|
+
animation: rec-dot-pulse 1.6s ease-in-out infinite;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
@keyframes rec-dot-pulse {
|
|
1823
|
+
0%,
|
|
1824
|
+
100% {
|
|
1825
|
+
opacity: 1;
|
|
1826
|
+
}
|
|
1827
|
+
50% {
|
|
1828
|
+
opacity: 0.4;
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
`;
|
|
1832
|
+
|
|
1833
|
+
/* Arming pill: same slot as RecBadge (a single forced recording shows one or
|
|
1834
|
+
the other, never both), a muted amber so it never reads as the red
|
|
1835
|
+
"recording is live" state: the feed hasn't actually started capturing
|
|
1836
|
+
yet, it's waiting for the resolution bump. */
|
|
1837
|
+
const ArmingBadge = styled.div`
|
|
1838
|
+
position: absolute;
|
|
1839
|
+
top: 28px;
|
|
1840
|
+
left: 8px;
|
|
1841
|
+
z-index: 2;
|
|
1842
|
+
display: flex;
|
|
1843
|
+
align-items: center;
|
|
1844
|
+
gap: 5px;
|
|
1845
|
+
padding: 2px 6px;
|
|
1846
|
+
background: rgba(0, 0, 0, 0.6);
|
|
1847
|
+
border: 1px solid var(--kerbcast-arming-active, #d9a441);
|
|
1848
|
+
border-radius: 3px;
|
|
1849
|
+
color: #fff;
|
|
1850
|
+
font-size: 10px;
|
|
1851
|
+
font-weight: 600;
|
|
1852
|
+
letter-spacing: 0.12em;
|
|
1853
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
1854
|
+
pointer-events: none;
|
|
1855
|
+
`;
|
|
1856
|
+
|
|
1857
|
+
const ArmingDot = styled.span`
|
|
1858
|
+
width: 7px;
|
|
1859
|
+
height: 7px;
|
|
1860
|
+
border-radius: 50%;
|
|
1861
|
+
background: var(--kerbcast-arming-active, #d9a441);
|
|
1862
|
+
flex-shrink: 0;
|
|
1863
|
+
|
|
1864
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
1865
|
+
animation: arming-dot-pulse 0.9s ease-in-out infinite;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
@keyframes arming-dot-pulse {
|
|
1869
|
+
0%,
|
|
1870
|
+
100% {
|
|
1871
|
+
opacity: 1;
|
|
1872
|
+
}
|
|
1873
|
+
50% {
|
|
1874
|
+
opacity: 0.3;
|
|
1875
|
+
}
|
|
1713
1876
|
}
|
|
1714
1877
|
`;
|
|
1715
1878
|
|
|
@@ -1744,14 +1907,14 @@ const Stage = styled(Panel)<{ $pinned: boolean }>`
|
|
|
1744
1907
|
&:focus-within ${ZoomControlsWrap},
|
|
1745
1908
|
&:hover ${PanControl},
|
|
1746
1909
|
&:focus-within ${PanControl},
|
|
1747
|
-
&:hover ${
|
|
1748
|
-
&:focus-within ${
|
|
1910
|
+
&:hover ${ActionBarRow},
|
|
1911
|
+
&:focus-within ${ActionBarRow} {
|
|
1749
1912
|
opacity: 1;
|
|
1750
1913
|
}
|
|
1751
1914
|
&:hover ${TopOverlay},
|
|
1752
1915
|
&:focus-within ${TopOverlay},
|
|
1753
|
-
&:hover ${
|
|
1754
|
-
&:focus-within ${
|
|
1916
|
+
&:hover ${ActionBarRow},
|
|
1917
|
+
&:focus-within ${ActionBarRow} {
|
|
1755
1918
|
pointer-events: auto;
|
|
1756
1919
|
}
|
|
1757
1920
|
|
|
@@ -1762,7 +1925,7 @@ const Stage = styled(Panel)<{ $pinned: boolean }>`
|
|
|
1762
1925
|
opacity: 1;
|
|
1763
1926
|
pointer-events: auto;
|
|
1764
1927
|
}
|
|
1765
|
-
${
|
|
1928
|
+
${ActionBarRow} {
|
|
1766
1929
|
opacity: 1;
|
|
1767
1930
|
pointer-events: auto;
|
|
1768
1931
|
}
|
|
@@ -1781,57 +1944,6 @@ const Empty = styled.div`
|
|
|
1781
1944
|
text-align: center;
|
|
1782
1945
|
`;
|
|
1783
1946
|
|
|
1784
|
-
const OverlayIconButton = styled.button<{ $active?: boolean }>`
|
|
1785
|
-
position: relative;
|
|
1786
|
-
width: 24px;
|
|
1787
|
-
height: 24px;
|
|
1788
|
-
display: flex;
|
|
1789
|
-
align-items: center;
|
|
1790
|
-
justify-content: center;
|
|
1791
|
-
padding: 0;
|
|
1792
|
-
/* Active-toggle fill (quality / tracking / PiP / fullscreen / custom actions
|
|
1793
|
-
all share this button). Its own token so a consumer can recolour just the
|
|
1794
|
-
action-row highlight without touching the general accent; defaults to
|
|
1795
|
-
--kerbcast-accent, so the sidecar web page (which maps its accent onto that)
|
|
1796
|
-
is unchanged. */
|
|
1797
|
-
background: ${(p) =>
|
|
1798
|
-
p.$active
|
|
1799
|
-
? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
|
|
1800
|
-
: "rgba(0, 0, 0, 0.5)"};
|
|
1801
|
-
border: 1px solid
|
|
1802
|
-
${(p) =>
|
|
1803
|
-
p.$active
|
|
1804
|
-
? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
|
|
1805
|
-
: "rgba(255, 255, 255, 0.3)"};
|
|
1806
|
-
border-radius: 3px;
|
|
1807
|
-
color: ${(p) => (p.$active ? "#000" : "#fff")};
|
|
1808
|
-
cursor: pointer;
|
|
1809
|
-
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
|
1810
|
-
|
|
1811
|
-
@media (hover: hover) {
|
|
1812
|
-
&:hover {
|
|
1813
|
-
background: ${(p) =>
|
|
1814
|
-
p.$active
|
|
1815
|
-
? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
|
|
1816
|
-
: "rgba(0, 0, 0, 0.7)"};
|
|
1817
|
-
border-color: rgba(255, 255, 255, 0.6);
|
|
1818
|
-
}
|
|
1819
|
-
}
|
|
1820
|
-
|
|
1821
|
-
&:focus-visible {
|
|
1822
|
-
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
1823
|
-
outline-offset: 2px;
|
|
1824
|
-
}
|
|
1825
|
-
|
|
1826
|
-
&:disabled {
|
|
1827
|
-
opacity: 0.4;
|
|
1828
|
-
}
|
|
1829
|
-
|
|
1830
|
-
@media (prefers-reduced-motion: reduce) {
|
|
1831
|
-
transition: none;
|
|
1832
|
-
}
|
|
1833
|
-
`;
|
|
1834
|
-
|
|
1835
1947
|
const StyledVideo = styled.video`
|
|
1836
1948
|
position: absolute;
|
|
1837
1949
|
inset: 0;
|