@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.8.0 - 2026-07-28
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `useRecordings`: a reactive recordings store over the SDK's
|
|
8
|
+
`client.recording` controller. Exposes finished standalone clips
|
|
9
|
+
(`recordings`), synchronised sets (`groups`), and in-progress recordings
|
|
10
|
+
(`active`, for a REC dot + elapsed timer), plus `start` / `stop` /
|
|
11
|
+
`startGroup` / `stopGroup` / `discard` / `discardGroup` / `isRecording`. This
|
|
12
|
+
is the headless data layer the recordings tray + REC controls sit on; it owns
|
|
13
|
+
no chrome and keeps grouped clips out of the singles list
|
|
14
|
+
- `RecordingsStore` and `ActiveRecordingInfo` types
|
|
15
|
+
|
|
3
16
|
## 1.7.0 - 2026-07-24
|
|
4
17
|
|
|
5
18
|
Crew face cameras, auto-resolution, and camera targeting.
|
package/dist/CameraFeed.d.ts
CHANGED
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
import type { CameraState, KerbcastClient } from "@ksp-gonogo/kerbcast";
|
|
2
|
+
import { type FeedAction } from "./FeedActionBar";
|
|
2
3
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The consumer-supplied action shape, and the shared top-right action bar
|
|
5
|
+
* both `CameraFeed` and `KerbalFaceFeed` render, live in `FeedActionBar`.
|
|
6
|
+
* Re-exported here since existing imports reference `./CameraFeed`.
|
|
6
7
|
*/
|
|
7
|
-
export
|
|
8
|
-
/** Stable identity for the React key. */
|
|
9
|
-
id: string;
|
|
10
|
-
/** Accessible label; used for aria-label and the native tooltip. */
|
|
11
|
-
label: string;
|
|
12
|
-
/** Icon node, sized by the action bar (~14px). */
|
|
13
|
-
icon: React.ReactNode;
|
|
14
|
-
/** Toggle state: renders the button highlighted and sets aria-pressed. */
|
|
15
|
-
active?: boolean;
|
|
16
|
-
onClick: () => void;
|
|
17
|
-
}
|
|
8
|
+
export type { FeedAction } from "./FeedActionBar";
|
|
18
9
|
/**
|
|
19
10
|
* A hook that yields the live `MediaStream` for a resolved flightId. Must be
|
|
20
11
|
* a stable reference (the same function identity every render) and called
|
|
@@ -125,6 +116,29 @@ export interface CameraFeedProps {
|
|
|
125
116
|
* the gimbal + FoV). Default false.
|
|
126
117
|
*/
|
|
127
118
|
enableTracking?: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* Show a built-in REC control in the action bar: a STATEFUL toggle (never
|
|
121
|
+
* overflow-eligible, per the #6 spec) that starts/stops a client-side
|
|
122
|
+
* recording of this feed via `useRecordings()`. Idle -> click starts
|
|
123
|
+
* (`start(flightId)`); active shows a red dot + a live elapsed timer on the
|
|
124
|
+
* tile -> click stops (`stop(recordingId)`) and the clip lands in the
|
|
125
|
+
* recordings store. Recording is purely client-local (this browser's own
|
|
126
|
+
* MediaRecorder, nothing server-authoritative) and read from the store
|
|
127
|
+
* (`isRecording(flightId)`), so it stays correct if toggled elsewhere (e.g.
|
|
128
|
+
* a REC+ grouped recording). Default false.
|
|
129
|
+
*/
|
|
130
|
+
enableRecording?: boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Record this feed at the operator's full render size for the duration of
|
|
133
|
+
* the built-in REC control's recording (overrides only the display-size
|
|
134
|
+
* demand; still yields to the adaptive framerate shed). Off by default.
|
|
135
|
+
* Threaded straight into `start(flightId, { forceFullResolution })`; has
|
|
136
|
+
* no effect unless `enableRecording` is also set. While the feed is armed
|
|
137
|
+
* (force sent, waiting for the resolution bump) the tile shows an
|
|
138
|
+
* "ARMING" pill under the title in place of the REC badge, then switches
|
|
139
|
+
* to the normal REC badge + elapsed timer once recording actually starts.
|
|
140
|
+
*/
|
|
141
|
+
recordFullResolution?: boolean;
|
|
128
142
|
/**
|
|
129
143
|
* Consumer-injected action buttons, rendered left of the built-in
|
|
130
144
|
* fullscreen/PiP controls in the top-right action bar.
|
|
@@ -132,8 +146,10 @@ export interface CameraFeedProps {
|
|
|
132
146
|
actions?: FeedAction[];
|
|
133
147
|
/**
|
|
134
148
|
* Consumer-injected action buttons rendered at the far end of the action
|
|
135
|
-
* bar
|
|
136
|
-
*
|
|
149
|
+
* bar: the natural home for a close/remove button. Always pinned trailing
|
|
150
|
+
* (never overflow-eligible, never counted toward the overflow threshold),
|
|
151
|
+
* so it sits in the corner as a persistent, single-click control, right of
|
|
152
|
+
* the ⋮ overflow trigger when one is present.
|
|
137
153
|
*/
|
|
138
154
|
trailingActions?: FeedAction[];
|
|
139
155
|
/**
|
package/dist/CameraFeed.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { PanZoomController, QualityPreset, TrackMode } from "@ksp-gonogo/kerbcast";
|
|
3
|
-
import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from "react";
|
|
3
|
+
import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useReducer, useRef, useState, } from "react";
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import styled, { css } from "styled-components";
|
|
6
6
|
import { buildCameraLabeler } from "./cameraLabels";
|
|
7
7
|
import { KerbcastProvider, useKerbcastClient } from "./context";
|
|
8
|
+
import { ActionBarRow, FeedActionBar, OverlayIconButton, feedActionToEntry, } from "./FeedActionBar";
|
|
8
9
|
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
9
10
|
import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
10
11
|
import { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
12
|
+
import { useRecordings } from "./hooks/useRecordings";
|
|
11
13
|
import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
12
14
|
import { StandbyIcon } from "./StandbyIcon";
|
|
13
15
|
import { isCameraDestroyed } from "./lifecycle";
|
|
16
|
+
import { usePortalMenu } from "./menuPositioning";
|
|
17
|
+
import { formatElapsed, nowMs } from "./timing";
|
|
14
18
|
// ---------------------------------------------------------------------------
|
|
15
19
|
// Tuning constants
|
|
16
20
|
// ---------------------------------------------------------------------------
|
|
@@ -19,97 +23,6 @@ const MENU_MAX_WIDTH = 260; // matches CameraMenu's CSS cap
|
|
|
19
23
|
const MENU_MAX_HEIGHT = 300; // matches CameraMenu's min(40vh, 300px) cap
|
|
20
24
|
const QUALITY_MENU_MAX_WIDTH = 220; // matches QualityMenu's CSS cap
|
|
21
25
|
const QUALITY_MENU_MAX_HEIGHT = 220; // matches QualityMenu's min(40vh, 220px) cap
|
|
22
|
-
const MENU_GAP = 4; // trigger-to-menu spacing
|
|
23
|
-
const MENU_EDGE = 8; // minimum inset from the viewport edge
|
|
24
|
-
/*
|
|
25
|
-
* Fixed-position style for a portaled menu, anchored to its trigger button.
|
|
26
|
-
* Opens downward by default; flips above the trigger when there is not
|
|
27
|
-
* enough room below but there is above, otherwise clamps to the viewport.
|
|
28
|
-
*/
|
|
29
|
-
function computeMenuPosition(trigger, anchor) {
|
|
30
|
-
const rect = trigger.getBoundingClientRect();
|
|
31
|
-
const vw = window.innerWidth;
|
|
32
|
-
const vh = window.innerHeight;
|
|
33
|
-
const width = Math.min(anchor.maxWidth, vw - 2 * MENU_EDGE);
|
|
34
|
-
/* Inline offset from the aligned viewport edge, clamped so a full-width
|
|
35
|
-
menu still fits inside the opposite edge. */
|
|
36
|
-
const inset = Math.max(MENU_EDGE, vw - width - MENU_EDGE);
|
|
37
|
-
const inline = anchor.align === "start"
|
|
38
|
-
? { left: Math.min(Math.max(rect.left, MENU_EDGE), inset) }
|
|
39
|
-
: { right: Math.min(Math.max(vw - rect.right, MENU_EDGE), inset) };
|
|
40
|
-
const menuH = Math.min(0.4 * vh, anchor.maxHeight);
|
|
41
|
-
const fitsBelow = rect.bottom + MENU_GAP + menuH <= vh - MENU_EDGE;
|
|
42
|
-
const fitsAbove = rect.top - MENU_GAP - menuH >= MENU_EDGE;
|
|
43
|
-
if (!fitsBelow && fitsAbove) {
|
|
44
|
-
return { ...inline, bottom: vh - rect.top + MENU_GAP };
|
|
45
|
-
}
|
|
46
|
-
const top = Math.min(rect.bottom + MENU_GAP, Math.max(MENU_EDGE, vh - MENU_EDGE - menuH));
|
|
47
|
-
return { ...inline, top };
|
|
48
|
-
}
|
|
49
|
-
/*
|
|
50
|
-
* Shared behaviour for a dropdown portaled to document.body: fixed position
|
|
51
|
-
* computed from the trigger's rect (so tile overflow cannot clip it),
|
|
52
|
-
* re-anchored on window resize/scroll while open, Escape to close with focus
|
|
53
|
-
* returned to the trigger, and portal-aware outside-pointer-down dismissal
|
|
54
|
-
* (menuRef points at the portaled menu, so clicks inside it stay "inside").
|
|
55
|
-
* One menu at a time falls out of the dismissal: opening another menu's
|
|
56
|
-
* trigger is an outside press for this one.
|
|
57
|
-
*/
|
|
58
|
-
function usePortalMenu({ maxWidth, maxHeight, align }) {
|
|
59
|
-
const [open, setOpen] = useState(false);
|
|
60
|
-
const [position, setPosition] = useState(null);
|
|
61
|
-
const menuRef = useRef(null);
|
|
62
|
-
const triggerRef = useRef(null);
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
if (!open) {
|
|
65
|
-
setPosition(null);
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
const update = () => {
|
|
69
|
-
const trigger = triggerRef.current;
|
|
70
|
-
if (trigger) {
|
|
71
|
-
setPosition(computeMenuPosition(trigger, { maxWidth, maxHeight, align }));
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
update();
|
|
75
|
-
window.addEventListener("resize", update);
|
|
76
|
-
window.addEventListener("scroll", update, true);
|
|
77
|
-
return () => {
|
|
78
|
-
window.removeEventListener("resize", update);
|
|
79
|
-
window.removeEventListener("scroll", update, true);
|
|
80
|
-
};
|
|
81
|
-
}, [open, maxWidth, maxHeight, align]);
|
|
82
|
-
useEffect(() => {
|
|
83
|
-
if (!open)
|
|
84
|
-
return;
|
|
85
|
-
const onKeyDown = (e) => {
|
|
86
|
-
if (e.key === "Escape") {
|
|
87
|
-
e.stopPropagation();
|
|
88
|
-
setOpen(false);
|
|
89
|
-
triggerRef.current?.focus();
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
const onPointerDown = (e) => {
|
|
93
|
-
if (!menuRef.current?.contains(e.target) &&
|
|
94
|
-
!triggerRef.current?.contains(e.target)) {
|
|
95
|
-
setOpen(false);
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
document.addEventListener("keydown", onKeyDown);
|
|
99
|
-
document.addEventListener("pointerdown", onPointerDown);
|
|
100
|
-
return () => {
|
|
101
|
-
document.removeEventListener("keydown", onKeyDown);
|
|
102
|
-
document.removeEventListener("pointerdown", onPointerDown);
|
|
103
|
-
};
|
|
104
|
-
}, [open]);
|
|
105
|
-
const toggle = useCallback(() => setOpen((v) => !v), []);
|
|
106
|
-
/** Close after an item pick, returning focus to the trigger. */
|
|
107
|
-
const close = useCallback(() => {
|
|
108
|
-
setOpen(false);
|
|
109
|
-
triggerRef.current?.focus();
|
|
110
|
-
}, []);
|
|
111
|
-
return { open, toggle, close, position, menuRef, triggerRef };
|
|
112
|
-
}
|
|
113
26
|
/*
|
|
114
27
|
* Viewer quality presets, in menu order. Scales mirror the sidecar's
|
|
115
28
|
* QualityPreset mapping (fractions of the camera's operator render size);
|
|
@@ -146,8 +59,11 @@ function exitFullscreen() {
|
|
|
146
59
|
// ---------------------------------------------------------------------------
|
|
147
60
|
// Inner component (reads from context)
|
|
148
61
|
// ---------------------------------------------------------------------------
|
|
149
|
-
const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, cameraFilter, onDisplayedCameraChange, showDebugInfo = false, showStatic, inFlight: inFlightProp, showStandbyIcon = true, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, enableTracking = false, actions, trailingActions, showActions = true, useStream, }, ref) {
|
|
62
|
+
const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, cameraFilter, onDisplayedCameraChange, showDebugInfo = false, showStatic, inFlight: inFlightProp, showStandbyIcon = true, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, enableTracking = false, enableRecording = false, recordFullResolution = false, actions, trailingActions, showActions = true, useStream, }, ref) {
|
|
150
63
|
const client = useKerbcastClient();
|
|
64
|
+
// Always called (rules of hooks); `enableRecording` only gates whether the
|
|
65
|
+
// REC entry/badge render, not whether the store is subscribed.
|
|
66
|
+
const recordings = useRecordings();
|
|
151
67
|
// The selectable set: every live camera, optionally narrowed by the host's
|
|
152
68
|
// cameraFilter (e.g. a part-grid tile offering only part cams). Drives the
|
|
153
69
|
// picker menu, the stepper, and the auto-latch fallback so none can land on
|
|
@@ -343,7 +259,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
343
259
|
onSelectCamera?.(nextId);
|
|
344
260
|
}, [cameras, currentIndex, onSelectCamera]);
|
|
345
261
|
// -------------------------------------------------------------------------
|
|
346
|
-
// PanZoomController
|
|
262
|
+
// PanZoomController: one per displayed camera
|
|
347
263
|
// -------------------------------------------------------------------------
|
|
348
264
|
const controllerRef = useRef(null);
|
|
349
265
|
const [sliderFov, setSliderFov] = useState(60);
|
|
@@ -358,8 +274,8 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
358
274
|
controllerRef.current?.dispose();
|
|
359
275
|
controllerRef.current = ctrl;
|
|
360
276
|
const unsubSlider = ctrl.onSliderFov(setSliderFov);
|
|
361
|
-
// Seed the slider from the controller's initial (0)
|
|
362
|
-
// immediately by syncFromState in the camera-state effect below.
|
|
277
|
+
// Seed the slider from the controller's initial (0). It will be
|
|
278
|
+
// overwritten immediately by syncFromState in the camera-state effect below.
|
|
363
279
|
setSliderFov(ctrl.sliderFov);
|
|
364
280
|
return () => {
|
|
365
281
|
unsubSlider();
|
|
@@ -524,7 +440,54 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
524
440
|
void client.setTrackTarget(flightId, next);
|
|
525
441
|
closeTrackingMenu();
|
|
526
442
|
}, [client, flightId, trackMode, closeTrackingMenu]);
|
|
527
|
-
|
|
443
|
+
// -------------------------------------------------------------------------
|
|
444
|
+
// REC control (opt-in). A stateful toggle that starts/stops a client-side
|
|
445
|
+
// recording of this feed via the recordings store; state is read from the
|
|
446
|
+
// store (isRecording), never held locally, so it stays correct if the
|
|
447
|
+
// recording was started elsewhere (e.g. a REC+ grouped recording).
|
|
448
|
+
// -------------------------------------------------------------------------
|
|
449
|
+
const recordingActive = enableRecording && flightId !== null && recordings.isRecording(flightId);
|
|
450
|
+
const activeRecording = recordingActive
|
|
451
|
+
? recordings.active.find((a) => a.flightId === flightId)
|
|
452
|
+
: undefined;
|
|
453
|
+
/* True while a single forced recording is armed (force sent, waiting for
|
|
454
|
+
the feed to reach full resolution) but the recorder has not started
|
|
455
|
+
yet (see ActiveRecordingInfo.arming). Drives the ARMING pill below in
|
|
456
|
+
place of the REC badge. */
|
|
457
|
+
const armingActive = activeRecording?.arming === true;
|
|
458
|
+
// Ticks once a second while recording so the elapsed timer stays live;
|
|
459
|
+
// the timer itself is derived from activeRecording.startedAt each render.
|
|
460
|
+
const [, tickRecordingTimer] = useReducer((c) => c + 1, 0);
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
if (!recordingActive)
|
|
463
|
+
return;
|
|
464
|
+
const interval = setInterval(tickRecordingTimer, 1000);
|
|
465
|
+
return () => clearInterval(interval);
|
|
466
|
+
}, [recordingActive]);
|
|
467
|
+
const recordingElapsedMs = activeRecording ? nowMs() - activeRecording.startedAt : 0;
|
|
468
|
+
const toggleRecording = useCallback(() => {
|
|
469
|
+
if (flightId === null)
|
|
470
|
+
return;
|
|
471
|
+
if (recordingActive) {
|
|
472
|
+
const active = recordings.active.find((a) => a.flightId === flightId);
|
|
473
|
+
if (active) {
|
|
474
|
+
void recordings.stop(active.recordingId).catch(() => {
|
|
475
|
+
/* A rejected stop shouldn't leave the REC toggle wedged; the
|
|
476
|
+
store's own state (active/recordings) is the source of truth
|
|
477
|
+
for whether it's still "recording", not this call's outcome. */
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
recordings.start(flightId, { forceFullResolution: recordFullResolution });
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
/* no live track yet, or a race with a recording already in progress
|
|
487
|
+
elsewhere on this feed; the operator can retry once live. */
|
|
488
|
+
}
|
|
489
|
+
}, [flightId, recordingActive, recordings, recordFullResolution]);
|
|
490
|
+
const topOverlay = (_jsxs(TopOverlay, { children: [_jsx(TitleRow, { children: _jsx(TopTitle, { children: hasCameras ? (_jsxs(TitleButton, { ref: cameraMenu.triggerRef, type: "button", "aria-haspopup": "menu", "aria-expanded": cameraMenu.open, "aria-controls": menuId, disabled: recordingActive, title: recordingActive ? "Stop recording to change camera" : undefined, onClick: recordingActive ? undefined : cameraMenu.toggle, children: [_jsx(TitleButton__Text, { children: title }), _jsx(ChevronDownIcon, { "aria-hidden": "true" })] })) : (title) }) }), cameraMenu.open &&
|
|
528
491
|
hasCameras &&
|
|
529
492
|
cameraMenu.position &&
|
|
530
493
|
createPortal(_jsx(CameraMenu, { ref: cameraMenu.menuRef, id: menuId, role: "menu", "aria-label": "Camera", style: cameraMenu.position, children: cameras.map((c) => (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": c.flightId === flightId, "$selected": c.flightId === flightId, onClick: () => {
|
|
@@ -532,21 +495,105 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
532
495
|
cameraMenu.close();
|
|
533
496
|
}, children: [cameraLabel(c), " (", c.vesselName, ")", isCameraDestroyed(c) ? " - signal lost" : ""] }, c.flightId))) }), document.body), showDebugInfo &&
|
|
534
497
|
(camera ? (_jsxs(TopMeta, { children: [camera.vesselName, " \u00B7 ", camera.renderWidth, "\u00D7", camera.renderHeight, bitrateLabel, adaptiveLabel] })) : (_jsx(TopMeta, { children: "no cameras on this vessel" })))] }));
|
|
535
|
-
|
|
498
|
+
/*
|
|
499
|
+
* Built-in action-bar entries, in the bar's natural (fallback) order:
|
|
500
|
+
* consumer actions, quality, tracking, PiP, fullscreen, trailing actions.
|
|
501
|
+
* FeedActionBar partitions these into primary/overflow (see its own
|
|
502
|
+
* doc-comment); tracking is the only built-in marked `stateful` today,
|
|
503
|
+
* per #6's "stateful toggles stay primary" rule (REC joins it the same
|
|
504
|
+
* way).
|
|
505
|
+
*/
|
|
506
|
+
const trackingLabel = tracking
|
|
507
|
+
? trackMode === TrackMode.Target
|
|
508
|
+
? "Auto-tracking target"
|
|
509
|
+
: "Auto-tracking active vessel"
|
|
510
|
+
: "Auto-track a vessel";
|
|
511
|
+
const pipLabel = isPip ? "Exit picture in picture" : "Picture in picture";
|
|
512
|
+
const fullscreenLabel = isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
|
|
513
|
+
const recordingLabel = recordingActive ? "Stop recording" : "Start recording";
|
|
514
|
+
const entries = [
|
|
515
|
+
...(actions?.map(feedActionToEntry) ?? []),
|
|
516
|
+
...(qualityAvailable
|
|
517
|
+
? [
|
|
518
|
+
{
|
|
519
|
+
id: "quality",
|
|
520
|
+
label: "Quality",
|
|
521
|
+
stateful: false,
|
|
522
|
+
render: () => (_jsxs(OverlayIconButton, { ref: qualityMenu.triggerRef, type: "button", "aria-label": "Quality", "aria-haspopup": "menu", "aria-expanded": qualityMenu.open, "aria-controls": qualityMenuId, title: qualityThrottled
|
|
523
|
+
? "Quality (throttled by adaptive performance)"
|
|
524
|
+
: "Quality", "$active": qualityMenu.open, onClick: qualityMenu.toggle, children: [_jsx(QualityIcon, {}), qualityThrottled && _jsx(ThrottledDot, { "aria-hidden": "true" })] })),
|
|
525
|
+
},
|
|
526
|
+
]
|
|
527
|
+
: []),
|
|
528
|
+
...(enableRecording && flightId !== null
|
|
529
|
+
? [
|
|
530
|
+
{
|
|
531
|
+
id: "record",
|
|
532
|
+
label: recordingLabel,
|
|
533
|
+
stateful: true,
|
|
534
|
+
render: () => (_jsx(RecordActionButton, { type: "button", "aria-label": recordingLabel, "aria-pressed": recordingActive, title: recordingLabel, "$active": recordingActive, onClick: toggleRecording, children: _jsx(RecordIcon, {}) })),
|
|
535
|
+
},
|
|
536
|
+
]
|
|
537
|
+
: []),
|
|
538
|
+
...(trackingAvailable
|
|
539
|
+
? [
|
|
540
|
+
{
|
|
541
|
+
id: "tracking",
|
|
542
|
+
label: trackingLabel,
|
|
543
|
+
stateful: true,
|
|
544
|
+
render: () => (_jsx(OverlayIconButton, { ref: trackingMenu.triggerRef, type: "button", "aria-label": "Auto-track", "aria-haspopup": "menu", "aria-expanded": trackingMenu.open, "aria-controls": trackingMenuId, "aria-pressed": tracking, title: trackingLabel, "$active": tracking || trackingMenu.open, onClick: trackingMenu.toggle, children: _jsx(CrosshairIcon, {}) })),
|
|
545
|
+
},
|
|
546
|
+
]
|
|
547
|
+
: []),
|
|
548
|
+
...(flightId !== null && pipAvailable
|
|
549
|
+
? [
|
|
550
|
+
{
|
|
551
|
+
id: "pip",
|
|
552
|
+
label: pipLabel,
|
|
553
|
+
stateful: false,
|
|
554
|
+
render: () => (_jsx(OverlayIconButton, { type: "button", "aria-label": pipLabel, "aria-pressed": isPip, title: pipLabel, "$active": isPip, onClick: togglePip, children: _jsx(PictureInPictureIcon, {}) })),
|
|
555
|
+
},
|
|
556
|
+
]
|
|
557
|
+
: []),
|
|
558
|
+
...(flightId !== null && fullscreenAvailable
|
|
559
|
+
? [
|
|
560
|
+
{
|
|
561
|
+
id: "fullscreen",
|
|
562
|
+
label: fullscreenLabel,
|
|
563
|
+
stateful: false,
|
|
564
|
+
render: () => (_jsx(OverlayIconButton, { type: "button", "aria-label": fullscreenLabel, "aria-pressed": isFullscreen, title: fullscreenLabel, "$active": isFullscreen, onClick: toggleFullscreen, children: isFullscreen ? _jsx(FullscreenExitIcon, {}) : _jsx(FullscreenEnterIcon, {}) })),
|
|
565
|
+
},
|
|
566
|
+
]
|
|
567
|
+
: []),
|
|
568
|
+
/* trailingActions is always the pinned-trailing (close/remove) slot,
|
|
569
|
+
regardless of what the consumer's FeedAction sets. See its own
|
|
570
|
+
doc-comment on CameraFeedProps. While a recording is active, closing
|
|
571
|
+
the feed would lose it, so the control stays visible but disabled
|
|
572
|
+
and inert rather than disappearing. */
|
|
573
|
+
...(trailingActions?.map((a) => {
|
|
574
|
+
const entry = feedActionToEntry(a);
|
|
575
|
+
if (!recordingActive)
|
|
576
|
+
return { ...entry, pinnedTrailing: true };
|
|
577
|
+
return {
|
|
578
|
+
...entry,
|
|
579
|
+
pinnedTrailing: true,
|
|
580
|
+
render: () => (_jsx(OverlayIconButton, { type: "button", "aria-label": a.label, title: "Stop recording to close this feed", disabled: true, "$active": a.active ?? false, children: a.icon }, a.id)),
|
|
581
|
+
};
|
|
582
|
+
}) ?? []),
|
|
583
|
+
];
|
|
536
584
|
const builtInActions = flightId !== null &&
|
|
537
|
-
(pipAvailable ||
|
|
585
|
+
(pipAvailable ||
|
|
586
|
+
fullscreenAvailable ||
|
|
587
|
+
qualityAvailable ||
|
|
588
|
+
trackingAvailable ||
|
|
589
|
+
enableRecording);
|
|
538
590
|
const hasActionBar = showActions &&
|
|
539
591
|
(hasCameras ||
|
|
540
592
|
(actions && actions.length > 0) ||
|
|
541
593
|
(trailingActions && trailingActions.length > 0) ||
|
|
542
594
|
builtInActions);
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
: "Quality", "$active": qualityMenu.open, onClick: qualityMenu.toggle, children: [_jsx(QualityIcon, {}), qualityThrottled && _jsx(ThrottledDot, { "aria-hidden": "true" })] })), trackingAvailable && (_jsx(OverlayIconButton, { ref: trackingMenu.triggerRef, type: "button", "aria-label": "Auto-track", "aria-haspopup": "menu", "aria-expanded": trackingMenu.open, "aria-controls": trackingMenuId, "aria-pressed": tracking, title: tracking
|
|
546
|
-
? trackMode === TrackMode.Target
|
|
547
|
-
? "Auto-tracking target"
|
|
548
|
-
: "Auto-tracking active vessel"
|
|
549
|
-
: "Auto-track a vessel", "$active": tracking || trackingMenu.open, onClick: trackingMenu.toggle, children: _jsx(CrosshairIcon, {}) })), flightId !== null && pipAvailable && (_jsx(OverlayIconButton, { type: "button", "aria-label": isPip ? "Exit picture in picture" : "Picture in picture", "aria-pressed": isPip, title: isPip ? "Exit picture in picture" : "Picture in picture", "$active": isPip, onClick: togglePip, children: _jsx(PictureInPictureIcon, {}) })), flightId !== null && fullscreenAvailable && (_jsx(OverlayIconButton, { type: "button", "aria-label": isFullscreen ? "Exit fullscreen" : "Enter fullscreen", "aria-pressed": isFullscreen, title: isFullscreen ? "Exit fullscreen" : "Enter fullscreen", "$active": isFullscreen, onClick: toggleFullscreen, children: isFullscreen ? _jsx(FullscreenExitIcon, {}) : _jsx(FullscreenEnterIcon, {}) })), trailingActions?.map(renderAction)] })) : null;
|
|
595
|
+
const stepButtons = hasCameras ? (_jsxs(StepButtons, { children: [_jsx(OverlayIconButton, { type: "button", "aria-label": "Previous camera", disabled: !canStep, onClick: () => stepCamera(-1), children: "\u2039" }), _jsx(OverlayIconButton, { type: "button", "aria-label": "Next camera", disabled: !canStep, onClick: () => stepCamera(1), children: "\u203A" })] })) : null;
|
|
596
|
+
const actionBar = hasActionBar ? (_jsx(FeedActionBar, { leading: stepButtons, entries: entries })) : null;
|
|
550
597
|
return (_jsx(Stage, { ref: wrapRef, "$pinned": chromePinned, children: flightId === null ? (_jsxs(_Fragment, { children: [_jsx(Empty, { children: emptyMessage }), topOverlay, actionBar] })) : (_jsxs(_Fragment, { children: [_jsx(StyledVideo, { ref: videoRef, autoPlay: true, playsInline: true, muted: true, controls: false, onClick: () => setChromePinned((v) => !v) }), topOverlay, actionBar, qualityAvailable &&
|
|
551
598
|
qualityMenu.open &&
|
|
552
599
|
qualityMenu.position &&
|
|
@@ -555,7 +602,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
555
602
|
trackingMenu.open &&
|
|
556
603
|
trackingMenu.position &&
|
|
557
604
|
camera &&
|
|
558
|
-
createPortal(_jsxs(QualityMenu, { ref: trackingMenu.menuRef, id: trackingMenuId, role: "menu", "aria-label": "Auto-track", style: trackingMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.ActiveVessel, "$selected": trackMode === TrackMode.ActiveVessel, onClick: () => selectTrack(TrackMode.ActiveVessel), children: "Track active vessel" }), _jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.Target, "$selected": trackMode === TrackMode.Target, onClick: () => selectTrack(TrackMode.Target), children: "Track target" })] }), document.body), outOfFlight ? (showStandbyIcon && (_jsx(StandbyOverlay, { role: "status", "aria-label": "Standby, no active flight", children: _jsx(StandbyIconWrap, { children: _jsx(StandbyIcon, { size: 40 }) }) }))) : (isDestroyed && (_jsx(SignalLostOverlay, { role: "status", "aria-label": "Signal lost", children: _jsx(SignalLostText, { "$animated": effectiveShowStatic, children: "SIGNAL LOST" }) }))), !outOfFlight && !effectiveShowStatic && isStale && !isDestroyed && (_jsxs(_Fragment, { children: [_jsx(StaleScrim, { "aria-hidden": "true" }), _jsxs(StaleBadge, { role: "status", "aria-label": "Feed stale", children: [_jsx(StaleIcon, {}), "Stale"] })] })), showZoom && (_jsxs(ZoomControlsWrap, { "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(ZoomButton, { type: "button", "aria-label": "Zoom in", "$pos": "top", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
|
|
605
|
+
createPortal(_jsxs(QualityMenu, { ref: trackingMenu.menuRef, id: trackingMenuId, role: "menu", "aria-label": "Auto-track", style: trackingMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.ActiveVessel, "$selected": trackMode === TrackMode.ActiveVessel, onClick: () => selectTrack(TrackMode.ActiveVessel), children: "Track active vessel" }), _jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.Target, "$selected": trackMode === TrackMode.Target, onClick: () => selectTrack(TrackMode.Target), children: "Track target" })] }), document.body), outOfFlight ? (showStandbyIcon && (_jsx(StandbyOverlay, { role: "status", "aria-label": "Standby, no active flight", children: _jsx(StandbyIconWrap, { children: _jsx(StandbyIcon, { size: 40 }) }) }))) : (isDestroyed && (_jsx(SignalLostOverlay, { role: "status", "aria-label": "Signal lost", children: _jsx(SignalLostText, { "$animated": effectiveShowStatic, children: "SIGNAL LOST" }) }))), !outOfFlight && !effectiveShowStatic && isStale && !isDestroyed && (_jsxs(_Fragment, { children: [_jsx(StaleScrim, { "aria-hidden": "true" }), _jsxs(StaleBadge, { role: "status", "aria-label": "Feed stale", children: [_jsx(StaleIcon, {}), "Stale"] })] })), recordingActive && armingActive && (_jsxs(ArmingBadge, { role: "status", "aria-label": "Arming", children: [_jsx(ArmingDot, { "aria-hidden": "true" }), "ARMING"] })), recordingActive && !armingActive && (_jsxs(RecBadge, { role: "status", "aria-label": "Recording", children: [_jsx(RecDot, { "aria-hidden": "true" }), "REC ", formatElapsed(recordingElapsedMs)] })), showZoom && (_jsxs(ZoomControlsWrap, { "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(ZoomButton, { type: "button", "aria-label": "Zoom in", "$pos": "top", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
|
|
559
606
|
if (e.detail === 0)
|
|
560
607
|
controllerRef.current?.nudgeZoom(-1);
|
|
561
608
|
}, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
|
|
@@ -626,6 +673,10 @@ function StaleIcon() {
|
|
|
626
673
|
function PictureInPictureIcon() {
|
|
627
674
|
return (_jsxs("svg", { ...iconProps, children: [_jsx("rect", { x: "2", y: "3", width: "12", height: "10", rx: "1" }), _jsx("rect", { x: "8", y: "8", width: "5", height: "4", rx: "0.5", fill: "currentColor", stroke: "none" })] }));
|
|
628
675
|
}
|
|
676
|
+
/* REC: a plain filled dot, coloured by RecordActionButton's own $active state. */
|
|
677
|
+
function RecordIcon() {
|
|
678
|
+
return (_jsx("svg", { viewBox: "0 0 16 16", width: 14, height: 14, fill: "currentColor", stroke: "none", "aria-hidden": "true", children: _jsx("circle", { cx: "8", cy: "8", r: "5" }) }));
|
|
679
|
+
}
|
|
629
680
|
// ---------------------------------------------------------------------------
|
|
630
681
|
// Styled components
|
|
631
682
|
// ---------------------------------------------------------------------------
|
|
@@ -933,21 +984,107 @@ const ThrottledDot = styled.span `
|
|
|
933
984
|
background: #ffb347;
|
|
934
985
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6);
|
|
935
986
|
`;
|
|
936
|
-
/*
|
|
937
|
-
|
|
987
|
+
/* REC action-bar button: same shape as OverlayIconButton, its own red
|
|
988
|
+
$active colour (a coming-recording toggle should never read as the
|
|
989
|
+
green "on" of tracking/quality) via its own token so a consumer can
|
|
990
|
+
recolour it without touching --kerbcast-action-active. */
|
|
991
|
+
const RecordActionButton = styled(OverlayIconButton) `
|
|
992
|
+
background: ${(p) => p.$active ? "var(--kerbcast-rec-active, #ff3b30)" : "rgba(0, 0, 0, 0.5)"};
|
|
993
|
+
border-color: ${(p) => p.$active ? "var(--kerbcast-rec-active, #ff3b30)" : "rgba(255, 255, 255, 0.3)"};
|
|
994
|
+
color: #fff;
|
|
995
|
+
|
|
996
|
+
@media (hover: hover) {
|
|
997
|
+
&:hover {
|
|
998
|
+
background: ${(p) => p.$active ? "var(--kerbcast-rec-active, #ff3b30)" : "rgba(0, 0, 0, 0.7)"};
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
`;
|
|
1002
|
+
/* Persistent recording badge: red dot + elapsed timer, always visible while
|
|
1003
|
+
recording (not hover chrome), same convention as StaleBadge. */
|
|
1004
|
+
const RecBadge = styled.div `
|
|
938
1005
|
position: absolute;
|
|
939
|
-
top
|
|
940
|
-
|
|
941
|
-
|
|
1006
|
+
/* Sits just under the hover-gated title/source dropdown (both are top-left)
|
|
1007
|
+
so an active recording stays visible without displacing that control. */
|
|
1008
|
+
top: 28px;
|
|
1009
|
+
left: 8px;
|
|
1010
|
+
z-index: 2;
|
|
942
1011
|
display: flex;
|
|
943
1012
|
align-items: center;
|
|
944
|
-
gap:
|
|
945
|
-
|
|
1013
|
+
gap: 5px;
|
|
1014
|
+
padding: 2px 6px;
|
|
1015
|
+
background: rgba(0, 0, 0, 0.6);
|
|
1016
|
+
border: 1px solid var(--kerbcast-rec-active, #ff3b30);
|
|
1017
|
+
border-radius: 3px;
|
|
1018
|
+
color: #fff;
|
|
1019
|
+
font-size: 10px;
|
|
1020
|
+
font-weight: 600;
|
|
1021
|
+
letter-spacing: 0.12em;
|
|
1022
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
946
1023
|
pointer-events: none;
|
|
947
|
-
|
|
1024
|
+
`;
|
|
1025
|
+
const RecDot = styled.span `
|
|
1026
|
+
width: 7px;
|
|
1027
|
+
height: 7px;
|
|
1028
|
+
border-radius: 50%;
|
|
1029
|
+
background: var(--kerbcast-rec-active, #ff3b30);
|
|
1030
|
+
flex-shrink: 0;
|
|
948
1031
|
|
|
949
|
-
@media (prefers-reduced-motion:
|
|
950
|
-
|
|
1032
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
1033
|
+
animation: rec-dot-pulse 1.6s ease-in-out infinite;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
@keyframes rec-dot-pulse {
|
|
1037
|
+
0%,
|
|
1038
|
+
100% {
|
|
1039
|
+
opacity: 1;
|
|
1040
|
+
}
|
|
1041
|
+
50% {
|
|
1042
|
+
opacity: 0.4;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
`;
|
|
1046
|
+
/* Arming pill: same slot as RecBadge (a single forced recording shows one or
|
|
1047
|
+
the other, never both), a muted amber so it never reads as the red
|
|
1048
|
+
"recording is live" state: the feed hasn't actually started capturing
|
|
1049
|
+
yet, it's waiting for the resolution bump. */
|
|
1050
|
+
const ArmingBadge = styled.div `
|
|
1051
|
+
position: absolute;
|
|
1052
|
+
top: 28px;
|
|
1053
|
+
left: 8px;
|
|
1054
|
+
z-index: 2;
|
|
1055
|
+
display: flex;
|
|
1056
|
+
align-items: center;
|
|
1057
|
+
gap: 5px;
|
|
1058
|
+
padding: 2px 6px;
|
|
1059
|
+
background: rgba(0, 0, 0, 0.6);
|
|
1060
|
+
border: 1px solid var(--kerbcast-arming-active, #d9a441);
|
|
1061
|
+
border-radius: 3px;
|
|
1062
|
+
color: #fff;
|
|
1063
|
+
font-size: 10px;
|
|
1064
|
+
font-weight: 600;
|
|
1065
|
+
letter-spacing: 0.12em;
|
|
1066
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
1067
|
+
pointer-events: none;
|
|
1068
|
+
`;
|
|
1069
|
+
const ArmingDot = styled.span `
|
|
1070
|
+
width: 7px;
|
|
1071
|
+
height: 7px;
|
|
1072
|
+
border-radius: 50%;
|
|
1073
|
+
background: var(--kerbcast-arming-active, #d9a441);
|
|
1074
|
+
flex-shrink: 0;
|
|
1075
|
+
|
|
1076
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
1077
|
+
animation: arming-dot-pulse 0.9s ease-in-out infinite;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
@keyframes arming-dot-pulse {
|
|
1081
|
+
0%,
|
|
1082
|
+
100% {
|
|
1083
|
+
opacity: 1;
|
|
1084
|
+
}
|
|
1085
|
+
50% {
|
|
1086
|
+
opacity: 0.3;
|
|
1087
|
+
}
|
|
951
1088
|
}
|
|
952
1089
|
`;
|
|
953
1090
|
/*
|
|
@@ -980,14 +1117,14 @@ const Stage = styled(Panel) `
|
|
|
980
1117
|
&:focus-within ${ZoomControlsWrap},
|
|
981
1118
|
&:hover ${PanControl},
|
|
982
1119
|
&:focus-within ${PanControl},
|
|
983
|
-
&:hover ${
|
|
984
|
-
&:focus-within ${
|
|
1120
|
+
&:hover ${ActionBarRow},
|
|
1121
|
+
&:focus-within ${ActionBarRow} {
|
|
985
1122
|
opacity: 1;
|
|
986
1123
|
}
|
|
987
1124
|
&:hover ${TopOverlay},
|
|
988
1125
|
&:focus-within ${TopOverlay},
|
|
989
|
-
&:hover ${
|
|
990
|
-
&:focus-within ${
|
|
1126
|
+
&:hover ${ActionBarRow},
|
|
1127
|
+
&:focus-within ${ActionBarRow} {
|
|
991
1128
|
pointer-events: auto;
|
|
992
1129
|
}
|
|
993
1130
|
|
|
@@ -997,7 +1134,7 @@ const Stage = styled(Panel) `
|
|
|
997
1134
|
opacity: 1;
|
|
998
1135
|
pointer-events: auto;
|
|
999
1136
|
}
|
|
1000
|
-
${
|
|
1137
|
+
${ActionBarRow} {
|
|
1001
1138
|
opacity: 1;
|
|
1002
1139
|
pointer-events: auto;
|
|
1003
1140
|
}
|
|
@@ -1014,53 +1151,6 @@ const Empty = styled.div `
|
|
|
1014
1151
|
padding: 1rem;
|
|
1015
1152
|
text-align: center;
|
|
1016
1153
|
`;
|
|
1017
|
-
const OverlayIconButton = styled.button `
|
|
1018
|
-
position: relative;
|
|
1019
|
-
width: 24px;
|
|
1020
|
-
height: 24px;
|
|
1021
|
-
display: flex;
|
|
1022
|
-
align-items: center;
|
|
1023
|
-
justify-content: center;
|
|
1024
|
-
padding: 0;
|
|
1025
|
-
/* Active-toggle fill (quality / tracking / PiP / fullscreen / custom actions
|
|
1026
|
-
all share this button). Its own token so a consumer can recolour just the
|
|
1027
|
-
action-row highlight without touching the general accent; defaults to
|
|
1028
|
-
--kerbcast-accent, so the sidecar web page (which maps its accent onto that)
|
|
1029
|
-
is unchanged. */
|
|
1030
|
-
background: ${(p) => p.$active
|
|
1031
|
-
? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
|
|
1032
|
-
: "rgba(0, 0, 0, 0.5)"};
|
|
1033
|
-
border: 1px solid
|
|
1034
|
-
${(p) => p.$active
|
|
1035
|
-
? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
|
|
1036
|
-
: "rgba(255, 255, 255, 0.3)"};
|
|
1037
|
-
border-radius: 3px;
|
|
1038
|
-
color: ${(p) => (p.$active ? "#000" : "#fff")};
|
|
1039
|
-
cursor: pointer;
|
|
1040
|
-
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
|
1041
|
-
|
|
1042
|
-
@media (hover: hover) {
|
|
1043
|
-
&:hover {
|
|
1044
|
-
background: ${(p) => p.$active
|
|
1045
|
-
? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
|
|
1046
|
-
: "rgba(0, 0, 0, 0.7)"};
|
|
1047
|
-
border-color: rgba(255, 255, 255, 0.6);
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
&:focus-visible {
|
|
1052
|
-
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
1053
|
-
outline-offset: 2px;
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
&:disabled {
|
|
1057
|
-
opacity: 0.4;
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
@media (prefers-reduced-motion: reduce) {
|
|
1061
|
-
transition: none;
|
|
1062
|
-
}
|
|
1063
|
-
`;
|
|
1064
1154
|
const StyledVideo = styled.video `
|
|
1065
1155
|
position: absolute;
|
|
1066
1156
|
inset: 0;
|