@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
package/dist/CameraFeed.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { PanZoomController, QualityPreset } from "@ksp-gonogo/kerbcast";
|
|
3
|
-
import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from "react";
|
|
2
|
+
import { PanZoomController, QualityPreset, TrackMode } from "@ksp-gonogo/kerbcast";
|
|
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";
|
|
13
|
+
import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
11
14
|
import { StandbyIcon } from "./StandbyIcon";
|
|
12
15
|
import { isCameraDestroyed } from "./lifecycle";
|
|
16
|
+
import { usePortalMenu } from "./menuPositioning";
|
|
17
|
+
import { formatElapsed, nowMs } from "./timing";
|
|
13
18
|
// ---------------------------------------------------------------------------
|
|
14
19
|
// Tuning constants
|
|
15
20
|
// ---------------------------------------------------------------------------
|
|
@@ -18,101 +23,6 @@ const MENU_MAX_WIDTH = 260; // matches CameraMenu's CSS cap
|
|
|
18
23
|
const MENU_MAX_HEIGHT = 300; // matches CameraMenu's min(40vh, 300px) cap
|
|
19
24
|
const QUALITY_MENU_MAX_WIDTH = 220; // matches QualityMenu's CSS cap
|
|
20
25
|
const QUALITY_MENU_MAX_HEIGHT = 220; // matches QualityMenu's min(40vh, 220px) cap
|
|
21
|
-
const MENU_GAP = 4; // trigger-to-menu spacing
|
|
22
|
-
const MENU_EDGE = 8; // minimum inset from the viewport edge
|
|
23
|
-
/*
|
|
24
|
-
* Fixed-position style for a portaled menu, anchored to its trigger button.
|
|
25
|
-
* Opens downward by default; flips above the trigger when there is not
|
|
26
|
-
* enough room below but there is above, otherwise clamps to the viewport.
|
|
27
|
-
*/
|
|
28
|
-
function computeMenuPosition(trigger, anchor) {
|
|
29
|
-
const rect = trigger.getBoundingClientRect();
|
|
30
|
-
const vw = window.innerWidth;
|
|
31
|
-
const vh = window.innerHeight;
|
|
32
|
-
const width = Math.min(anchor.maxWidth, vw - 2 * MENU_EDGE);
|
|
33
|
-
/* Inline offset from the aligned viewport edge, clamped so a full-width
|
|
34
|
-
menu still fits inside the opposite edge. */
|
|
35
|
-
const inset = Math.max(MENU_EDGE, vw - width - MENU_EDGE);
|
|
36
|
-
const inline = anchor.align === "start"
|
|
37
|
-
? { left: Math.min(Math.max(rect.left, MENU_EDGE), inset) }
|
|
38
|
-
: { right: Math.min(Math.max(vw - rect.right, MENU_EDGE), inset) };
|
|
39
|
-
const menuH = Math.min(0.4 * vh, anchor.maxHeight);
|
|
40
|
-
const fitsBelow = rect.bottom + MENU_GAP + menuH <= vh - MENU_EDGE;
|
|
41
|
-
const fitsAbove = rect.top - MENU_GAP - menuH >= MENU_EDGE;
|
|
42
|
-
if (!fitsBelow && fitsAbove) {
|
|
43
|
-
return { ...inline, bottom: vh - rect.top + MENU_GAP };
|
|
44
|
-
}
|
|
45
|
-
const top = Math.min(rect.bottom + MENU_GAP, Math.max(MENU_EDGE, vh - MENU_EDGE - menuH));
|
|
46
|
-
return { ...inline, top };
|
|
47
|
-
}
|
|
48
|
-
/*
|
|
49
|
-
* Shared behaviour for a dropdown portaled to document.body: fixed position
|
|
50
|
-
* computed from the trigger's rect (so tile overflow cannot clip it),
|
|
51
|
-
* re-anchored on window resize/scroll while open, Escape to close with focus
|
|
52
|
-
* returned to the trigger, and portal-aware outside-pointer-down dismissal
|
|
53
|
-
* (menuRef points at the portaled menu, so clicks inside it stay "inside").
|
|
54
|
-
* One menu at a time falls out of the dismissal: opening another menu's
|
|
55
|
-
* trigger is an outside press for this one.
|
|
56
|
-
*/
|
|
57
|
-
function usePortalMenu({ maxWidth, maxHeight, align }) {
|
|
58
|
-
const [open, setOpen] = useState(false);
|
|
59
|
-
const [position, setPosition] = useState(null);
|
|
60
|
-
const menuRef = useRef(null);
|
|
61
|
-
const triggerRef = useRef(null);
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
if (!open) {
|
|
64
|
-
setPosition(null);
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
const update = () => {
|
|
68
|
-
const trigger = triggerRef.current;
|
|
69
|
-
if (trigger) {
|
|
70
|
-
setPosition(computeMenuPosition(trigger, { maxWidth, maxHeight, align }));
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
|
-
update();
|
|
74
|
-
window.addEventListener("resize", update);
|
|
75
|
-
window.addEventListener("scroll", update, true);
|
|
76
|
-
return () => {
|
|
77
|
-
window.removeEventListener("resize", update);
|
|
78
|
-
window.removeEventListener("scroll", update, true);
|
|
79
|
-
};
|
|
80
|
-
}, [open, maxWidth, maxHeight, align]);
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
if (!open)
|
|
83
|
-
return;
|
|
84
|
-
const onKeyDown = (e) => {
|
|
85
|
-
if (e.key === "Escape") {
|
|
86
|
-
e.stopPropagation();
|
|
87
|
-
setOpen(false);
|
|
88
|
-
triggerRef.current?.focus();
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
const onPointerDown = (e) => {
|
|
92
|
-
if (!menuRef.current?.contains(e.target) &&
|
|
93
|
-
!triggerRef.current?.contains(e.target)) {
|
|
94
|
-
setOpen(false);
|
|
95
|
-
}
|
|
96
|
-
};
|
|
97
|
-
document.addEventListener("keydown", onKeyDown);
|
|
98
|
-
document.addEventListener("pointerdown", onPointerDown);
|
|
99
|
-
return () => {
|
|
100
|
-
document.removeEventListener("keydown", onKeyDown);
|
|
101
|
-
document.removeEventListener("pointerdown", onPointerDown);
|
|
102
|
-
};
|
|
103
|
-
}, [open]);
|
|
104
|
-
const toggle = useCallback(() => setOpen((v) => !v), []);
|
|
105
|
-
/** Close after an item pick, returning focus to the trigger. */
|
|
106
|
-
const close = useCallback(() => {
|
|
107
|
-
setOpen(false);
|
|
108
|
-
triggerRef.current?.focus();
|
|
109
|
-
}, []);
|
|
110
|
-
return { open, toggle, close, position, menuRef, triggerRef };
|
|
111
|
-
}
|
|
112
|
-
/** Round n to the nearest even integer, minimum 2 (H.264 chroma requirement). */
|
|
113
|
-
function toEvenPx(n) {
|
|
114
|
-
return Math.max(2, Math.round(n / 2) * 2);
|
|
115
|
-
}
|
|
116
26
|
/*
|
|
117
27
|
* Viewer quality presets, in menu order. Scales mirror the sidecar's
|
|
118
28
|
* QualityPreset mapping (fractions of the camera's operator render size);
|
|
@@ -149,9 +59,17 @@ function exitFullscreen() {
|
|
|
149
59
|
// ---------------------------------------------------------------------------
|
|
150
60
|
// Inner component (reads from context)
|
|
151
61
|
// ---------------------------------------------------------------------------
|
|
152
|
-
const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, 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, actions, trailingActions, 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) {
|
|
153
63
|
const client = useKerbcastClient();
|
|
154
|
-
|
|
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();
|
|
67
|
+
// The selectable set: every live camera, optionally narrowed by the host's
|
|
68
|
+
// cameraFilter (e.g. a part-grid tile offering only part cams). Drives the
|
|
69
|
+
// picker menu, the stepper, and the auto-latch fallback so none can land on
|
|
70
|
+
// a filtered-out camera.
|
|
71
|
+
const allCameras = useKerbcastCameras();
|
|
72
|
+
const cameras = useMemo(() => (cameraFilter ? allCameras.filter(cameraFilter) : allCameras), [allCameras, cameraFilter]);
|
|
155
73
|
const inFlightFromClient = useKerbcastInFlight();
|
|
156
74
|
const inFlight = inFlightProp ?? inFlightFromClient;
|
|
157
75
|
const outOfFlight = inFlight === false;
|
|
@@ -316,6 +234,13 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
316
234
|
const isDestroyed = camera ? isCameraDestroyed(camera) : false;
|
|
317
235
|
const showPan = camera?.supportsPan && !isDestroyed;
|
|
318
236
|
const showZoom = camera?.supportsZoom && !isDestroyed;
|
|
237
|
+
// Auto-track state is server-authoritative: driven purely by the published
|
|
238
|
+
// trackMode (absent -> none), never local click. While tracking, the aim
|
|
239
|
+
// loop owns the gimbal + FoV, so the manual pan/zoom controls are disabled
|
|
240
|
+
// (they jitter against the track and are no-ops in practice).
|
|
241
|
+
const trackMode = camera?.trackMode ?? TrackMode.None;
|
|
242
|
+
const tracking = trackMode !== TrackMode.None;
|
|
243
|
+
const manualDisabled = tracking;
|
|
319
244
|
const supportsPitch = !!camera && camera.panPitchMax - camera.panPitchMin > 0;
|
|
320
245
|
// -------------------------------------------------------------------------
|
|
321
246
|
// Camera selection callbacks (Next/Prev, picker)
|
|
@@ -334,7 +259,7 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
334
259
|
onSelectCamera?.(nextId);
|
|
335
260
|
}, [cameras, currentIndex, onSelectCamera]);
|
|
336
261
|
// -------------------------------------------------------------------------
|
|
337
|
-
// PanZoomController
|
|
262
|
+
// PanZoomController: one per displayed camera
|
|
338
263
|
// -------------------------------------------------------------------------
|
|
339
264
|
const controllerRef = useRef(null);
|
|
340
265
|
const [sliderFov, setSliderFov] = useState(60);
|
|
@@ -349,8 +274,8 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
349
274
|
controllerRef.current?.dispose();
|
|
350
275
|
controllerRef.current = ctrl;
|
|
351
276
|
const unsubSlider = ctrl.onSliderFov(setSliderFov);
|
|
352
|
-
// Seed the slider from the controller's initial (0)
|
|
353
|
-
// 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.
|
|
354
279
|
setSliderFov(ctrl.sliderFov);
|
|
355
280
|
return () => {
|
|
356
281
|
unsubSlider();
|
|
@@ -441,36 +366,12 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
441
366
|
},
|
|
442
367
|
}), [stepCamera, showZoom, showPan, supportsPitch]);
|
|
443
368
|
// -------------------------------------------------------------------------
|
|
444
|
-
//
|
|
369
|
+
// Display-size reporting (auto-resolution). The feed self-measures its
|
|
370
|
+
// rendered box and reports it per-consumer; the sidecar maxes across
|
|
371
|
+
// consumers. Reports the real w x h (part cams are not square). Disabled by
|
|
372
|
+
// `renderSize="none"`. See useReportDisplaySize for debounce / bucketing.
|
|
445
373
|
// -------------------------------------------------------------------------
|
|
446
|
-
|
|
447
|
-
useEffect(() => {
|
|
448
|
-
if (renderSize !== "auto" || flightId === null)
|
|
449
|
-
return;
|
|
450
|
-
const el = wrapRef.current;
|
|
451
|
-
if (!el)
|
|
452
|
-
return;
|
|
453
|
-
const cam = client.camera(flightId);
|
|
454
|
-
const observer = new ResizeObserver((entries) => {
|
|
455
|
-
const entry = entries[0];
|
|
456
|
-
if (!entry)
|
|
457
|
-
return;
|
|
458
|
-
const { width } = entry.contentRect;
|
|
459
|
-
if (resizeTimerRef.current !== null)
|
|
460
|
-
clearTimeout(resizeTimerRef.current);
|
|
461
|
-
resizeTimerRef.current = setTimeout(() => {
|
|
462
|
-
const w = toEvenPx(width);
|
|
463
|
-
const h = toEvenPx((width * 9) / 16);
|
|
464
|
-
void cam.setRenderSize(w, h);
|
|
465
|
-
}, 500);
|
|
466
|
-
});
|
|
467
|
-
observer.observe(el);
|
|
468
|
-
return () => {
|
|
469
|
-
observer.disconnect();
|
|
470
|
-
if (resizeTimerRef.current !== null)
|
|
471
|
-
clearTimeout(resizeTimerRef.current);
|
|
472
|
-
};
|
|
473
|
-
}, [client, flightId, renderSize]);
|
|
374
|
+
useReportDisplaySize(flightId, wrapRef, { enabled: renderSize === "auto" });
|
|
474
375
|
// -------------------------------------------------------------------------
|
|
475
376
|
// UI state
|
|
476
377
|
// -------------------------------------------------------------------------
|
|
@@ -512,7 +413,81 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
512
413
|
void client.camera(flightId).setQuality(preset);
|
|
513
414
|
closeQualityMenu();
|
|
514
415
|
}, [client, flightId, closeQualityMenu]);
|
|
515
|
-
|
|
416
|
+
// -------------------------------------------------------------------------
|
|
417
|
+
// Auto-track control (opt-in, pan+zoom cameras only). A crosshair button +
|
|
418
|
+
// menu with the two modes. Sends the intent; the highlight follows the
|
|
419
|
+
// server-published trackMode (above), so two browsers agree.
|
|
420
|
+
// -------------------------------------------------------------------------
|
|
421
|
+
const trackingAvailable = enableTracking &&
|
|
422
|
+
flightId !== null &&
|
|
423
|
+
camera !== null &&
|
|
424
|
+
camera.supportsPan === true &&
|
|
425
|
+
camera.supportsZoom === true &&
|
|
426
|
+
!isDestroyed;
|
|
427
|
+
const trackingMenuId = useId();
|
|
428
|
+
const trackingMenu = usePortalMenu({
|
|
429
|
+
maxWidth: QUALITY_MENU_MAX_WIDTH,
|
|
430
|
+
maxHeight: QUALITY_MENU_MAX_HEIGHT,
|
|
431
|
+
align: "end",
|
|
432
|
+
});
|
|
433
|
+
const closeTrackingMenu = trackingMenu.close;
|
|
434
|
+
const selectTrack = useCallback((mode) => {
|
|
435
|
+
if (flightId === null)
|
|
436
|
+
return;
|
|
437
|
+
// Tri-state: clicking the already-active mode hands aiming back (none).
|
|
438
|
+
// Decision reads the server-confirmed trackMode, not a local toggle.
|
|
439
|
+
const next = trackMode === mode ? TrackMode.None : mode;
|
|
440
|
+
void client.setTrackTarget(flightId, next);
|
|
441
|
+
closeTrackingMenu();
|
|
442
|
+
}, [client, flightId, trackMode, closeTrackingMenu]);
|
|
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 &&
|
|
516
491
|
hasCameras &&
|
|
517
492
|
cameraMenu.position &&
|
|
518
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: () => {
|
|
@@ -520,23 +495,117 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
520
495
|
cameraMenu.close();
|
|
521
496
|
}, children: [cameraLabel(c), " (", c.vesselName, ")", isCameraDestroyed(c) ? " - signal lost" : ""] }, c.flightId))) }), document.body), showDebugInfo &&
|
|
522
497
|
(camera ? (_jsxs(TopMeta, { children: [camera.vesselName, " \u00B7 ", camera.renderWidth, "\u00D7", camera.renderHeight, bitrateLabel, adaptiveLabel] })) : (_jsx(TopMeta, { children: "no cameras on this vessel" })))] }));
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
+
];
|
|
584
|
+
const builtInActions = flightId !== null &&
|
|
585
|
+
(pipAvailable ||
|
|
586
|
+
fullscreenAvailable ||
|
|
587
|
+
qualityAvailable ||
|
|
588
|
+
trackingAvailable ||
|
|
589
|
+
enableRecording);
|
|
590
|
+
const hasActionBar = showActions &&
|
|
591
|
+
(hasCameras ||
|
|
592
|
+
(actions && actions.length > 0) ||
|
|
593
|
+
(trailingActions && trailingActions.length > 0) ||
|
|
594
|
+
builtInActions);
|
|
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;
|
|
532
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 &&
|
|
533
598
|
qualityMenu.open &&
|
|
534
599
|
qualityMenu.position &&
|
|
535
600
|
camera &&
|
|
536
|
-
createPortal(_jsxs(QualityMenu, { ref: qualityMenu.menuRef, id: qualityMenuId, role: "menu", "aria-label": "Quality", style: qualityMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality == null, "$selected": camera.viewerQuality == null, onClick: () => selectQuality(null), children: "Auto" }), QUALITY_PRESETS.map(({ preset, label, scale }) => (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality === preset, "$selected": camera.viewerQuality === preset, onClick: () => selectQuality(preset), children: [label, " (", presetDim(camera.operatorWidth, scale), "\u00D7", presetDim(camera.operatorHeight, scale), ")"] }, preset))), _jsxs(QualityMeta, { role: "status", children: ["now ", camera.renderWidth, "\u00D7", camera.renderHeight, qualityThrottled ? " · throttled" : ""] })] }), document.body),
|
|
601
|
+
createPortal(_jsxs(QualityMenu, { ref: qualityMenu.menuRef, id: qualityMenuId, role: "menu", "aria-label": "Quality", style: qualityMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality == null, "$selected": camera.viewerQuality == null, onClick: () => selectQuality(null), children: "Auto" }), QUALITY_PRESETS.map(({ preset, label, scale }) => (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality === preset, "$selected": camera.viewerQuality === preset, onClick: () => selectQuality(preset), children: [label, " (", presetDim(camera.operatorWidth, scale), "\u00D7", presetDim(camera.operatorHeight, scale), ")"] }, preset))), _jsxs(QualityMeta, { role: "status", children: ["now ", camera.renderWidth, "\u00D7", camera.renderHeight, qualityThrottled ? " · throttled" : ""] })] }), document.body), trackingAvailable &&
|
|
602
|
+
trackingMenu.open &&
|
|
603
|
+
trackingMenu.position &&
|
|
604
|
+
camera &&
|
|
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) => {
|
|
537
606
|
if (e.detail === 0)
|
|
538
607
|
controllerRef.current?.nudgeZoom(-1);
|
|
539
|
-
}, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, onChange: (e) => {
|
|
608
|
+
}, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
|
|
540
609
|
const v = Number(e.target.value);
|
|
541
610
|
controllerRef.current?.fovSliderInput(v);
|
|
542
611
|
}, onPointerDown: () => {
|
|
@@ -545,10 +614,10 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
|
|
|
545
614
|
controllerRef.current?.setFovSliderDragging(false);
|
|
546
615
|
}, onPointerCancel: () => {
|
|
547
616
|
controllerRef.current?.setFovSliderDragging(false);
|
|
548
|
-
} }), _jsx(ZoomButton, { type: "button", "aria-label": "Zoom out", "$pos": "bottom", onPointerDown: () => controllerRef.current?.setZoomRate(-1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
|
|
617
|
+
} }), _jsx(ZoomButton, { type: "button", "aria-label": "Zoom out", "$pos": "bottom", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(-1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
|
|
549
618
|
if (e.detail === 0)
|
|
550
619
|
controllerRef.current?.nudgeZoom(1);
|
|
551
|
-
}, children: "\u2212" })] })), showPan && (_jsxs(PanControl, { role: "group", "aria-label": "Pan camera", children: [_jsx(PanArrow, { type: "button", "$dir": "up", "aria-label": "Pan up", disabled: !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, 1), children: "\u25B2" }), _jsx(PanArrow, { type: "button", "$dir": "down", "aria-label": "Pan down", disabled: !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, -1), children: "\u25BC" }), _jsx(PanArrow, { type: "button", "$dir": "left", "aria-label": "Pan left", onClick: () => controllerRef.current?.nudgePan(-1, 0), children: "\u25C0" }), _jsx(PanArrow, { type: "button", "$dir": "right", "aria-label": "Pan right", onClick: () => controllerRef.current?.nudgePan(1, 0), children: "\u25B6" }), _jsx(PanBall, { "aria-hidden": "true", title: "Drag to pan", onPointerDown: handleBallDown, onPointerMove: handleBallMove, onPointerUp: handleBallUp, onPointerCancel: handleBallUp, style: {
|
|
620
|
+
}, children: "\u2212" })] })), showPan && (_jsxs(PanControl, { role: "group", "aria-label": "Pan camera", "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(PanArrow, { type: "button", "$dir": "up", "aria-label": "Pan up", disabled: manualDisabled || !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, 1), children: "\u25B2" }), _jsx(PanArrow, { type: "button", "$dir": "down", "aria-label": "Pan down", disabled: manualDisabled || !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, -1), children: "\u25BC" }), _jsx(PanArrow, { type: "button", "$dir": "left", "aria-label": "Pan left", disabled: manualDisabled, onClick: () => controllerRef.current?.nudgePan(-1, 0), children: "\u25C0" }), _jsx(PanArrow, { type: "button", "$dir": "right", "aria-label": "Pan right", disabled: manualDisabled, onClick: () => controllerRef.current?.nudgePan(1, 0), children: "\u25B6" }), _jsx(PanBall, { "aria-hidden": "true", title: "Drag to pan", onPointerDown: manualDisabled ? undefined : handleBallDown, onPointerMove: manualDisabled ? undefined : handleBallMove, onPointerUp: manualDisabled ? undefined : handleBallUp, onPointerCancel: manualDisabled ? undefined : handleBallUp, style: {
|
|
552
621
|
transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
|
|
553
622
|
} })] }))] })) }));
|
|
554
623
|
});
|
|
@@ -593,6 +662,10 @@ function FullscreenExitIcon() {
|
|
|
593
662
|
function QualityIcon() {
|
|
594
663
|
return (_jsxs("svg", { ...iconProps, children: [_jsx("path", { d: "M2 4.5h12M2 8h12M2 11.5h12" }), _jsx("circle", { cx: "10.5", cy: "4.5", r: "1.6", fill: "currentColor", stroke: "none" }), _jsx("circle", { cx: "5.5", cy: "8", r: "1.6", fill: "currentColor", stroke: "none" }), _jsx("circle", { cx: "8.5", cy: "11.5", r: "1.6", fill: "currentColor", stroke: "none" })] }));
|
|
595
664
|
}
|
|
665
|
+
/* Auto-track: a crosshair / reticle. */
|
|
666
|
+
function CrosshairIcon() {
|
|
667
|
+
return (_jsxs("svg", { ...iconProps, children: [_jsx("circle", { cx: "8", cy: "8", r: "4.5" }), _jsx("path", { d: "M8 1v2.5M8 12.5V15M1 8h2.5M12.5 8H15" })] }));
|
|
668
|
+
}
|
|
596
669
|
/* Stale badge: fading signal bars (tallest dimmed). */
|
|
597
670
|
function StaleIcon() {
|
|
598
671
|
return (_jsxs("svg", { viewBox: "0 0 16 16", width: 10, height: 10, fill: "currentColor", stroke: "none", "aria-hidden": "true", children: [_jsx("rect", { x: "2", y: "9", width: "2.5", height: "5" }), _jsx("rect", { x: "6.5", y: "6", width: "2.5", height: "8" }), _jsx("rect", { x: "11", y: "3", width: "2.5", height: "11", opacity: "0.35" })] }));
|
|
@@ -600,6 +673,10 @@ function StaleIcon() {
|
|
|
600
673
|
function PictureInPictureIcon() {
|
|
601
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" })] }));
|
|
602
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
|
+
}
|
|
603
680
|
// ---------------------------------------------------------------------------
|
|
604
681
|
// Styled components
|
|
605
682
|
// ---------------------------------------------------------------------------
|
|
@@ -613,6 +690,9 @@ const PanControl = styled.div `
|
|
|
613
690
|
transition: opacity 0.15s;
|
|
614
691
|
touch-action: none;
|
|
615
692
|
|
|
693
|
+
/* Greyed + inert while auto-tracking owns the gimbal. */
|
|
694
|
+
${(p) => p.$disabled && "pointer-events: none; filter: grayscale(1) opacity(0.4);"}
|
|
695
|
+
|
|
616
696
|
@media (prefers-reduced-motion: reduce) {
|
|
617
697
|
transition: none;
|
|
618
698
|
}
|
|
@@ -692,6 +772,9 @@ const ZoomControlsWrap = styled.div `
|
|
|
692
772
|
opacity: 0;
|
|
693
773
|
transition: opacity 0.15s;
|
|
694
774
|
|
|
775
|
+
/* Greyed + inert while auto-tracking owns the FoV. */
|
|
776
|
+
${(p) => p.$disabled && "pointer-events: none; filter: grayscale(1) opacity(0.4);"}
|
|
777
|
+
|
|
695
778
|
@media (prefers-reduced-motion: reduce) {
|
|
696
779
|
transition: none;
|
|
697
780
|
}
|
|
@@ -901,21 +984,107 @@ const ThrottledDot = styled.span `
|
|
|
901
984
|
background: #ffb347;
|
|
902
985
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6);
|
|
903
986
|
`;
|
|
904
|
-
/*
|
|
905
|
-
|
|
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 `
|
|
906
1005
|
position: absolute;
|
|
907
|
-
top
|
|
908
|
-
|
|
909
|
-
|
|
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;
|
|
910
1011
|
display: flex;
|
|
911
1012
|
align-items: center;
|
|
912
|
-
gap:
|
|
913
|
-
|
|
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);
|
|
914
1023
|
pointer-events: none;
|
|
915
|
-
|
|
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;
|
|
916
1031
|
|
|
917
|
-
@media (prefers-reduced-motion:
|
|
918
|
-
|
|
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
|
+
}
|
|
919
1088
|
}
|
|
920
1089
|
`;
|
|
921
1090
|
/*
|
|
@@ -948,14 +1117,14 @@ const Stage = styled(Panel) `
|
|
|
948
1117
|
&:focus-within ${ZoomControlsWrap},
|
|
949
1118
|
&:hover ${PanControl},
|
|
950
1119
|
&:focus-within ${PanControl},
|
|
951
|
-
&:hover ${
|
|
952
|
-
&:focus-within ${
|
|
1120
|
+
&:hover ${ActionBarRow},
|
|
1121
|
+
&:focus-within ${ActionBarRow} {
|
|
953
1122
|
opacity: 1;
|
|
954
1123
|
}
|
|
955
1124
|
&:hover ${TopOverlay},
|
|
956
1125
|
&:focus-within ${TopOverlay},
|
|
957
|
-
&:hover ${
|
|
958
|
-
&:focus-within ${
|
|
1126
|
+
&:hover ${ActionBarRow},
|
|
1127
|
+
&:focus-within ${ActionBarRow} {
|
|
959
1128
|
pointer-events: auto;
|
|
960
1129
|
}
|
|
961
1130
|
|
|
@@ -965,7 +1134,7 @@ const Stage = styled(Panel) `
|
|
|
965
1134
|
opacity: 1;
|
|
966
1135
|
pointer-events: auto;
|
|
967
1136
|
}
|
|
968
|
-
${
|
|
1137
|
+
${ActionBarRow} {
|
|
969
1138
|
opacity: 1;
|
|
970
1139
|
pointer-events: auto;
|
|
971
1140
|
}
|
|
@@ -982,48 +1151,6 @@ const Empty = styled.div `
|
|
|
982
1151
|
padding: 1rem;
|
|
983
1152
|
text-align: center;
|
|
984
1153
|
`;
|
|
985
|
-
const OverlayIconButton = styled.button `
|
|
986
|
-
position: relative;
|
|
987
|
-
width: 24px;
|
|
988
|
-
height: 24px;
|
|
989
|
-
display: flex;
|
|
990
|
-
align-items: center;
|
|
991
|
-
justify-content: center;
|
|
992
|
-
padding: 0;
|
|
993
|
-
background: ${(p) => p.$active
|
|
994
|
-
? "var(--kerbcast-accent, #00ff88)"
|
|
995
|
-
: "rgba(0, 0, 0, 0.5)"};
|
|
996
|
-
border: 1px solid
|
|
997
|
-
${(p) => p.$active
|
|
998
|
-
? "var(--kerbcast-accent, #00ff88)"
|
|
999
|
-
: "rgba(255, 255, 255, 0.3)"};
|
|
1000
|
-
border-radius: 3px;
|
|
1001
|
-
color: ${(p) => (p.$active ? "#000" : "#fff")};
|
|
1002
|
-
cursor: pointer;
|
|
1003
|
-
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
|
1004
|
-
|
|
1005
|
-
@media (hover: hover) {
|
|
1006
|
-
&:hover {
|
|
1007
|
-
background: ${(p) => p.$active
|
|
1008
|
-
? "var(--kerbcast-accent, #00ff88)"
|
|
1009
|
-
: "rgba(0, 0, 0, 0.7)"};
|
|
1010
|
-
border-color: rgba(255, 255, 255, 0.6);
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
&:focus-visible {
|
|
1015
|
-
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
1016
|
-
outline-offset: 2px;
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
&:disabled {
|
|
1020
|
-
opacity: 0.4;
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
@media (prefers-reduced-motion: reduce) {
|
|
1024
|
-
transition: none;
|
|
1025
|
-
}
|
|
1026
|
-
`;
|
|
1027
1154
|
const StyledVideo = styled.video `
|
|
1028
1155
|
position: absolute;
|
|
1029
1156
|
inset: 0;
|