@ksp-gonogo/kerbcast-react 1.4.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 +26 -0
- package/LICENSE +369 -0
- package/README.md +26 -0
- package/dist/CameraFeed.d.ts +126 -0
- package/dist/CameraFeed.js +1110 -0
- package/dist/cameraLabels.d.ts +14 -0
- package/dist/cameraLabels.js +59 -0
- package/dist/context.d.ts +34 -0
- package/dist/context.js +70 -0
- package/dist/hooks/useKerbcamCameras.d.ts +8 -0
- package/dist/hooks/useKerbcamCameras.js +19 -0
- package/dist/hooks/useKerbcamStream.d.ts +10 -0
- package/dist/hooks/useKerbcamStream.js +35 -0
- package/dist/hooks/useKerbcastCameras.d.ts +8 -0
- package/dist/hooks/useKerbcastCameras.js +19 -0
- package/dist/hooks/useKerbcastClock.d.ts +20 -0
- package/dist/hooks/useKerbcastClock.js +26 -0
- package/dist/hooks/useKerbcastStream.d.ts +10 -0
- package/dist/hooks/useKerbcastStream.js +35 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +19 -0
- package/dist/lifecycle.d.ts +5 -0
- package/dist/lifecycle.js +9 -0
- package/package.json +67 -0
- package/src/CameraFeed.test.tsx +1608 -0
- package/src/CameraFeed.tsx +1757 -0
- package/src/cameraLabels.test.ts +82 -0
- package/src/cameraLabels.ts +68 -0
- package/src/context.tsx +129 -0
- package/src/hooks/useKerbcastCameras.ts +24 -0
- package/src/hooks/useKerbcastClock.test.tsx +73 -0
- package/src/hooks/useKerbcastClock.ts +33 -0
- package/src/hooks/useKerbcastStream.test.tsx +119 -0
- package/src/hooks/useKerbcastStream.ts +38 -0
- package/src/index.test.ts +10 -0
- package/src/index.ts +38 -0
- package/src/lifecycle.ts +14 -0
- package/src/test/setup.ts +3 -0
|
@@ -0,0 +1,1110 @@
|
|
|
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";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import styled, { css } from "styled-components";
|
|
6
|
+
import { buildCameraLabeler } from "./cameraLabels";
|
|
7
|
+
import { KerbcastProvider, useKerbcastClient } from "./context";
|
|
8
|
+
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
9
|
+
import { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
10
|
+
import { isCameraDestroyed } from "./lifecycle";
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Tuning constants
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
const PAN_BALL_RADIUS = 15; // pixel deflection bound (full = rate 1)
|
|
15
|
+
const MENU_MAX_WIDTH = 260; // matches CameraMenu's CSS cap
|
|
16
|
+
const MENU_MAX_HEIGHT = 300; // matches CameraMenu's min(40vh, 300px) cap
|
|
17
|
+
const QUALITY_MENU_MAX_WIDTH = 220; // matches QualityMenu's CSS cap
|
|
18
|
+
const QUALITY_MENU_MAX_HEIGHT = 220; // matches QualityMenu's min(40vh, 220px) cap
|
|
19
|
+
const MENU_GAP = 4; // trigger-to-menu spacing
|
|
20
|
+
const MENU_EDGE = 8; // minimum inset from the viewport edge
|
|
21
|
+
/*
|
|
22
|
+
* Fixed-position style for a portaled menu, anchored to its trigger button.
|
|
23
|
+
* Opens downward by default; flips above the trigger when there is not
|
|
24
|
+
* enough room below but there is above, otherwise clamps to the viewport.
|
|
25
|
+
*/
|
|
26
|
+
function computeMenuPosition(trigger, anchor) {
|
|
27
|
+
const rect = trigger.getBoundingClientRect();
|
|
28
|
+
const vw = window.innerWidth;
|
|
29
|
+
const vh = window.innerHeight;
|
|
30
|
+
const width = Math.min(anchor.maxWidth, vw - 2 * MENU_EDGE);
|
|
31
|
+
/* Inline offset from the aligned viewport edge, clamped so a full-width
|
|
32
|
+
menu still fits inside the opposite edge. */
|
|
33
|
+
const inset = Math.max(MENU_EDGE, vw - width - MENU_EDGE);
|
|
34
|
+
const inline = anchor.align === "start"
|
|
35
|
+
? { left: Math.min(Math.max(rect.left, MENU_EDGE), inset) }
|
|
36
|
+
: { right: Math.min(Math.max(vw - rect.right, MENU_EDGE), inset) };
|
|
37
|
+
const menuH = Math.min(0.4 * vh, anchor.maxHeight);
|
|
38
|
+
const fitsBelow = rect.bottom + MENU_GAP + menuH <= vh - MENU_EDGE;
|
|
39
|
+
const fitsAbove = rect.top - MENU_GAP - menuH >= MENU_EDGE;
|
|
40
|
+
if (!fitsBelow && fitsAbove) {
|
|
41
|
+
return { ...inline, bottom: vh - rect.top + MENU_GAP };
|
|
42
|
+
}
|
|
43
|
+
const top = Math.min(rect.bottom + MENU_GAP, Math.max(MENU_EDGE, vh - MENU_EDGE - menuH));
|
|
44
|
+
return { ...inline, top };
|
|
45
|
+
}
|
|
46
|
+
/*
|
|
47
|
+
* Shared behaviour for a dropdown portaled to document.body: fixed position
|
|
48
|
+
* computed from the trigger's rect (so tile overflow cannot clip it),
|
|
49
|
+
* re-anchored on window resize/scroll while open, Escape to close with focus
|
|
50
|
+
* returned to the trigger, and portal-aware outside-pointer-down dismissal
|
|
51
|
+
* (menuRef points at the portaled menu, so clicks inside it stay "inside").
|
|
52
|
+
* One menu at a time falls out of the dismissal: opening another menu's
|
|
53
|
+
* trigger is an outside press for this one.
|
|
54
|
+
*/
|
|
55
|
+
function usePortalMenu({ maxWidth, maxHeight, align }) {
|
|
56
|
+
const [open, setOpen] = useState(false);
|
|
57
|
+
const [position, setPosition] = useState(null);
|
|
58
|
+
const menuRef = useRef(null);
|
|
59
|
+
const triggerRef = useRef(null);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!open) {
|
|
62
|
+
setPosition(null);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const update = () => {
|
|
66
|
+
const trigger = triggerRef.current;
|
|
67
|
+
if (trigger) {
|
|
68
|
+
setPosition(computeMenuPosition(trigger, { maxWidth, maxHeight, align }));
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
update();
|
|
72
|
+
window.addEventListener("resize", update);
|
|
73
|
+
window.addEventListener("scroll", update, true);
|
|
74
|
+
return () => {
|
|
75
|
+
window.removeEventListener("resize", update);
|
|
76
|
+
window.removeEventListener("scroll", update, true);
|
|
77
|
+
};
|
|
78
|
+
}, [open, maxWidth, maxHeight, align]);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!open)
|
|
81
|
+
return;
|
|
82
|
+
const onKeyDown = (e) => {
|
|
83
|
+
if (e.key === "Escape") {
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
setOpen(false);
|
|
86
|
+
triggerRef.current?.focus();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const onPointerDown = (e) => {
|
|
90
|
+
if (!menuRef.current?.contains(e.target) &&
|
|
91
|
+
!triggerRef.current?.contains(e.target)) {
|
|
92
|
+
setOpen(false);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
document.addEventListener("keydown", onKeyDown);
|
|
96
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
97
|
+
return () => {
|
|
98
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
99
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
100
|
+
};
|
|
101
|
+
}, [open]);
|
|
102
|
+
const toggle = useCallback(() => setOpen((v) => !v), []);
|
|
103
|
+
/** Close after an item pick, returning focus to the trigger. */
|
|
104
|
+
const close = useCallback(() => {
|
|
105
|
+
setOpen(false);
|
|
106
|
+
triggerRef.current?.focus();
|
|
107
|
+
}, []);
|
|
108
|
+
return { open, toggle, close, position, menuRef, triggerRef };
|
|
109
|
+
}
|
|
110
|
+
/** Round n to the nearest even integer, minimum 2 (H.264 chroma requirement). */
|
|
111
|
+
function toEvenPx(n) {
|
|
112
|
+
return Math.max(2, Math.round(n / 2) * 2);
|
|
113
|
+
}
|
|
114
|
+
/*
|
|
115
|
+
* Viewer quality presets, in menu order. Scales mirror the sidecar's
|
|
116
|
+
* QualityPreset mapping (fractions of the camera's operator render size);
|
|
117
|
+
* the target-dims hint floors to even exactly like the plugin does.
|
|
118
|
+
*/
|
|
119
|
+
const QUALITY_PRESETS = [
|
|
120
|
+
{ preset: QualityPreset.Full, label: "Full", scale: 1.0 },
|
|
121
|
+
{ preset: QualityPreset.ThreeQuarter, label: "3/4", scale: 0.75 },
|
|
122
|
+
{ preset: QualityPreset.Half, label: "1/2", scale: 0.5 },
|
|
123
|
+
{ preset: QualityPreset.Quarter, label: "1/4", scale: 0.25 },
|
|
124
|
+
];
|
|
125
|
+
function presetDim(operatorDim, scale) {
|
|
126
|
+
const v = Math.trunc(operatorDim * scale) & ~1;
|
|
127
|
+
return v < 2 ? 2 : v;
|
|
128
|
+
}
|
|
129
|
+
function isFullscreenSupported() {
|
|
130
|
+
if (typeof document === "undefined")
|
|
131
|
+
return false;
|
|
132
|
+
const d = document;
|
|
133
|
+
return Boolean(d.fullscreenEnabled || d.webkitFullscreenEnabled);
|
|
134
|
+
}
|
|
135
|
+
function currentFullscreenElement() {
|
|
136
|
+
const d = document;
|
|
137
|
+
return d.fullscreenElement ?? d.webkitFullscreenElement ?? null;
|
|
138
|
+
}
|
|
139
|
+
function requestFullscreen(el) {
|
|
140
|
+
const e = el;
|
|
141
|
+
void (e.requestFullscreen?.() ?? e.webkitRequestFullscreen?.());
|
|
142
|
+
}
|
|
143
|
+
function exitFullscreen() {
|
|
144
|
+
const d = document;
|
|
145
|
+
void (d.exitFullscreen?.() ?? d.webkitExitFullscreen?.());
|
|
146
|
+
}
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Inner component (reads from context)
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requestedFlightId, onSelectCamera, onDisplayedCameraChange, showDebugInfo = false, showStatic, renderSize = "auto", emptyMessage = "No camera feeds - start a vessel with Hullcam parts installed", enableFullscreen = false, enablePictureInPicture = false, enableQualityControl = false, actions, trailingActions, useStream, }, ref) {
|
|
151
|
+
const client = useKerbcastClient();
|
|
152
|
+
const cameras = useKerbcastCameras();
|
|
153
|
+
/*
|
|
154
|
+
* Reduced-motion auto mode: when `showStatic` prop is undefined, read and
|
|
155
|
+
* track `prefers-reduced-motion: reduce`. Reduced motion defaults to off
|
|
156
|
+
* (no animated noise); normal motion defaults to on.
|
|
157
|
+
*/
|
|
158
|
+
const [reducedMotion, setReducedMotion] = useState(() => {
|
|
159
|
+
if (typeof window === "undefined")
|
|
160
|
+
return false;
|
|
161
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
162
|
+
});
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
if (showStatic !== undefined)
|
|
165
|
+
return;
|
|
166
|
+
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
167
|
+
const handler = (e) => setReducedMotion(e.matches);
|
|
168
|
+
mq.addEventListener("change", handler);
|
|
169
|
+
return () => mq.removeEventListener("change", handler);
|
|
170
|
+
}, [showStatic]);
|
|
171
|
+
const effectiveShowStatic = showStatic === undefined ? !reducedMotion : showStatic;
|
|
172
|
+
// -------------------------------------------------------------------------
|
|
173
|
+
// Selection model (mirrors gonogo's CameraFeed)
|
|
174
|
+
//
|
|
175
|
+
// Resolution order:
|
|
176
|
+
// 1. Explicit pick, if still present in the list (destroyed or not).
|
|
177
|
+
// 2. Auto: latch the currently-displayed camera. Keep showing it even if
|
|
178
|
+
// destroyed, but only while no other live camera exists (destroyed
|
|
179
|
+
// tombstones never leave the list on their own, so a destroyed latch
|
|
180
|
+
// would otherwise never release). Once a live camera is available,
|
|
181
|
+
// release the latch and auto-pick it.
|
|
182
|
+
// 3. Fresh auto-pick: prefer the first live camera; fall back to first
|
|
183
|
+
// overall only when every camera is destroyed.
|
|
184
|
+
// -------------------------------------------------------------------------
|
|
185
|
+
const displayedRef = useRef(null);
|
|
186
|
+
const requestedStillPresent = requestedFlightId !== null &&
|
|
187
|
+
cameras.some((c) => c.flightId === requestedFlightId);
|
|
188
|
+
let flightId;
|
|
189
|
+
if (requestedStillPresent) {
|
|
190
|
+
flightId = requestedFlightId;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
const latched = displayedRef.current;
|
|
194
|
+
const latchedCamera = latched !== null ? cameras.find((c) => c.flightId === latched) : undefined;
|
|
195
|
+
const anyLive = cameras.some((c) => !isCameraDestroyed(c));
|
|
196
|
+
const latchedPresent = latchedCamera !== undefined &&
|
|
197
|
+
(!isCameraDestroyed(latchedCamera) || !anyLive);
|
|
198
|
+
flightId = latchedPresent
|
|
199
|
+
? latched
|
|
200
|
+
: (cameras.find((c) => !isCameraDestroyed(c))?.flightId ??
|
|
201
|
+
cameras[0]?.flightId ??
|
|
202
|
+
null);
|
|
203
|
+
}
|
|
204
|
+
const camera = flightId !== null
|
|
205
|
+
? (cameras.find((c) => c.flightId === flightId) ?? null)
|
|
206
|
+
: null;
|
|
207
|
+
// Latest onDisplayedCameraChange, held in a ref so the commit effect below
|
|
208
|
+
// fires only on a resolved-flightId change, not on every render when the
|
|
209
|
+
// consumer passes an inline callback.
|
|
210
|
+
const onDisplayedRef = useRef(onDisplayedCameraChange);
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
onDisplayedRef.current = onDisplayedCameraChange;
|
|
213
|
+
});
|
|
214
|
+
// Commit on-screen camera for the auto-mode latch, and report which camera
|
|
215
|
+
// is actually displayed so consumers can label by it (auto-picks included).
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
displayedRef.current = flightId;
|
|
218
|
+
onDisplayedRef.current?.(flightId);
|
|
219
|
+
}, [flightId]);
|
|
220
|
+
// `flightId` here is the RESOLVED id (auto-latch / fallback applied), so a
|
|
221
|
+
// consumer-injected hook never has to duplicate that resolution. The
|
|
222
|
+
// built-in hook is the default. See CameraStreamHook's rules-of-hooks note.
|
|
223
|
+
const resolveStream = useStream ?? useKerbcastStream;
|
|
224
|
+
const stream = resolveStream(flightId);
|
|
225
|
+
const videoRef = useRef(null);
|
|
226
|
+
// The feed frame; fullscreen targets this and ResizeObserver measures it.
|
|
227
|
+
const wrapRef = useRef(null);
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
if (videoRef.current && stream) {
|
|
230
|
+
videoRef.current.srcObject = stream;
|
|
231
|
+
}
|
|
232
|
+
}, [stream]);
|
|
233
|
+
// -------------------------------------------------------------------------
|
|
234
|
+
// Stall presentation. The static is composited in-stream by the SDK's
|
|
235
|
+
// noise pipeline; this forwards the resolved setting to the camera handle
|
|
236
|
+
// and mirrors the handle's stall state for the no-static badge.
|
|
237
|
+
// -------------------------------------------------------------------------
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (flightId === null)
|
|
240
|
+
return;
|
|
241
|
+
client.camera(flightId).setShowStatic(effectiveShowStatic);
|
|
242
|
+
}, [client, flightId, effectiveShowStatic]);
|
|
243
|
+
const [isStale, setIsStale] = useState(false);
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (flightId === null) {
|
|
246
|
+
setIsStale(false);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const cam = client.camera(flightId);
|
|
250
|
+
setIsStale(cam.stalled);
|
|
251
|
+
return cam.on("stall", setIsStale);
|
|
252
|
+
}, [client, flightId]);
|
|
253
|
+
// -------------------------------------------------------------------------
|
|
254
|
+
// Fullscreen + Picture-in-Picture (opt-in, feature-detected)
|
|
255
|
+
// -------------------------------------------------------------------------
|
|
256
|
+
const fullscreenAvailable = enableFullscreen && isFullscreenSupported();
|
|
257
|
+
const pipAvailable = enablePictureInPicture &&
|
|
258
|
+
typeof document !== "undefined" &&
|
|
259
|
+
document.pictureInPictureEnabled === true;
|
|
260
|
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
261
|
+
const [isPip, setIsPip] = useState(false);
|
|
262
|
+
// Keep the fullscreen icon in sync however the user enters/exits (Esc, etc).
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
if (!fullscreenAvailable)
|
|
265
|
+
return;
|
|
266
|
+
const sync = () => setIsFullscreen(currentFullscreenElement() === wrapRef.current);
|
|
267
|
+
document.addEventListener("fullscreenchange", sync);
|
|
268
|
+
document.addEventListener("webkitfullscreenchange", sync);
|
|
269
|
+
sync();
|
|
270
|
+
return () => {
|
|
271
|
+
document.removeEventListener("fullscreenchange", sync);
|
|
272
|
+
document.removeEventListener("webkitfullscreenchange", sync);
|
|
273
|
+
};
|
|
274
|
+
}, [fullscreenAvailable]);
|
|
275
|
+
// PiP listeners re-attach when the <video> mounts/remounts (flightId change).
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
if (!pipAvailable)
|
|
278
|
+
return;
|
|
279
|
+
const v = videoRef.current;
|
|
280
|
+
if (!v)
|
|
281
|
+
return;
|
|
282
|
+
const onEnter = () => setIsPip(true);
|
|
283
|
+
const onLeave = () => setIsPip(false);
|
|
284
|
+
v.addEventListener("enterpictureinpicture", onEnter);
|
|
285
|
+
v.addEventListener("leavepictureinpicture", onLeave);
|
|
286
|
+
return () => {
|
|
287
|
+
v.removeEventListener("enterpictureinpicture", onEnter);
|
|
288
|
+
v.removeEventListener("leavepictureinpicture", onLeave);
|
|
289
|
+
};
|
|
290
|
+
}, [pipAvailable, flightId]);
|
|
291
|
+
const toggleFullscreen = useCallback(() => {
|
|
292
|
+
const el = wrapRef.current;
|
|
293
|
+
if (!el)
|
|
294
|
+
return;
|
|
295
|
+
if (currentFullscreenElement())
|
|
296
|
+
exitFullscreen();
|
|
297
|
+
else
|
|
298
|
+
requestFullscreen(el);
|
|
299
|
+
}, []);
|
|
300
|
+
const togglePip = useCallback(() => {
|
|
301
|
+
const v = videoRef.current;
|
|
302
|
+
if (!v)
|
|
303
|
+
return;
|
|
304
|
+
if (document.pictureInPictureElement) {
|
|
305
|
+
void document.exitPictureInPicture().catch(() => { });
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
void v.requestPictureInPicture().catch(() => { });
|
|
309
|
+
}
|
|
310
|
+
}, []);
|
|
311
|
+
const isDestroyed = camera ? isCameraDestroyed(camera) : false;
|
|
312
|
+
const showPan = camera?.supportsPan && !isDestroyed;
|
|
313
|
+
const showZoom = camera?.supportsZoom && !isDestroyed;
|
|
314
|
+
const supportsPitch = !!camera && camera.panPitchMax - camera.panPitchMin > 0;
|
|
315
|
+
// -------------------------------------------------------------------------
|
|
316
|
+
// Camera selection callbacks (Next/Prev, picker)
|
|
317
|
+
// onSelectCamera is ONLY called on explicit user picks, never on auto-latch.
|
|
318
|
+
// -------------------------------------------------------------------------
|
|
319
|
+
const currentIndex = useMemo(() => flightId !== null
|
|
320
|
+
? cameras.findIndex((c) => c.flightId === flightId)
|
|
321
|
+
: -1, [cameras, flightId]);
|
|
322
|
+
const stepCamera = useCallback((delta) => {
|
|
323
|
+
if (cameras.length === 0)
|
|
324
|
+
return;
|
|
325
|
+
const base = currentIndex >= 0 ? currentIndex : 0;
|
|
326
|
+
const next = (base + delta + cameras.length) % cameras.length;
|
|
327
|
+
const nextId = cameras[next]?.flightId;
|
|
328
|
+
if (nextId !== undefined)
|
|
329
|
+
onSelectCamera?.(nextId);
|
|
330
|
+
}, [cameras, currentIndex, onSelectCamera]);
|
|
331
|
+
// -------------------------------------------------------------------------
|
|
332
|
+
// PanZoomController -- one per displayed camera
|
|
333
|
+
// -------------------------------------------------------------------------
|
|
334
|
+
const controllerRef = useRef(null);
|
|
335
|
+
const [sliderFov, setSliderFov] = useState(60);
|
|
336
|
+
useEffect(() => {
|
|
337
|
+
if (flightId === null) {
|
|
338
|
+
controllerRef.current?.dispose();
|
|
339
|
+
controllerRef.current = null;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const cam = client.camera(flightId);
|
|
343
|
+
const ctrl = new PanZoomController(cam);
|
|
344
|
+
controllerRef.current?.dispose();
|
|
345
|
+
controllerRef.current = ctrl;
|
|
346
|
+
const unsubSlider = ctrl.onSliderFov(setSliderFov);
|
|
347
|
+
// Seed the slider from the controller's initial (0) -- will be overwritten
|
|
348
|
+
// immediately by syncFromState in the camera-state effect below.
|
|
349
|
+
setSliderFov(ctrl.sliderFov);
|
|
350
|
+
return () => {
|
|
351
|
+
unsubSlider();
|
|
352
|
+
ctrl.stop();
|
|
353
|
+
ctrl.dispose();
|
|
354
|
+
controllerRef.current = null;
|
|
355
|
+
};
|
|
356
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
357
|
+
}, [client, flightId]);
|
|
358
|
+
// Sync controller from camera state on every state update.
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
if (!camera || !controllerRef.current)
|
|
361
|
+
return;
|
|
362
|
+
controllerRef.current.syncFromState(camera);
|
|
363
|
+
}, [camera]);
|
|
364
|
+
// Stop pan/zoom when the control hides (signal lost / support dropped).
|
|
365
|
+
useEffect(() => {
|
|
366
|
+
if (!showPan) {
|
|
367
|
+
controllerRef.current?.setPanRate(0, 0);
|
|
368
|
+
setBallPos({ x: 0, y: 0 });
|
|
369
|
+
setBallActive(false);
|
|
370
|
+
}
|
|
371
|
+
}, [showPan]);
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
if (!showZoom)
|
|
374
|
+
controllerRef.current?.setZoomRate(0);
|
|
375
|
+
}, [showZoom]);
|
|
376
|
+
// -------------------------------------------------------------------------
|
|
377
|
+
// Ball drag state (pixel math lives here)
|
|
378
|
+
// -------------------------------------------------------------------------
|
|
379
|
+
const ballStartRef = useRef({ x: 0, y: 0 });
|
|
380
|
+
const [ballPos, setBallPos] = useState({ x: 0, y: 0 });
|
|
381
|
+
const [ballActive, setBallActive] = useState(false);
|
|
382
|
+
const handleBallDown = useCallback((e) => {
|
|
383
|
+
if (flightId === null)
|
|
384
|
+
return;
|
|
385
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
386
|
+
ballStartRef.current = { x: e.clientX, y: e.clientY };
|
|
387
|
+
setBallActive(true);
|
|
388
|
+
controllerRef.current?.setBallDragging(true);
|
|
389
|
+
}, [flightId]);
|
|
390
|
+
const handleBallMove = useCallback((e) => {
|
|
391
|
+
if (!ballActive)
|
|
392
|
+
return;
|
|
393
|
+
let dx = e.clientX - ballStartRef.current.x;
|
|
394
|
+
let dy = supportsPitch ? e.clientY - ballStartRef.current.y : 0;
|
|
395
|
+
const mag = Math.hypot(dx, dy);
|
|
396
|
+
if (mag > PAN_BALL_RADIUS) {
|
|
397
|
+
const k = PAN_BALL_RADIUS / mag;
|
|
398
|
+
dx *= k;
|
|
399
|
+
dy *= k;
|
|
400
|
+
}
|
|
401
|
+
setBallPos({ x: dx, y: dy });
|
|
402
|
+
controllerRef.current?.setPanRate(dx / PAN_BALL_RADIUS, -dy / PAN_BALL_RADIUS);
|
|
403
|
+
}, [ballActive, supportsPitch]);
|
|
404
|
+
const handleBallUp = useCallback(() => {
|
|
405
|
+
setBallActive(false);
|
|
406
|
+
setBallPos({ x: 0, y: 0 });
|
|
407
|
+
controllerRef.current?.setBallDragging(false);
|
|
408
|
+
controllerRef.current?.setPanRate(0, 0);
|
|
409
|
+
}, []);
|
|
410
|
+
// -------------------------------------------------------------------------
|
|
411
|
+
// Handle API (forwardRef)
|
|
412
|
+
// -------------------------------------------------------------------------
|
|
413
|
+
useImperativeHandle(ref, () => ({
|
|
414
|
+
stepCamera,
|
|
415
|
+
setZoomRate(rate) {
|
|
416
|
+
if (!showZoom)
|
|
417
|
+
return;
|
|
418
|
+
controllerRef.current?.setZoomRate(rate);
|
|
419
|
+
},
|
|
420
|
+
setPanAxis(axis, value) {
|
|
421
|
+
if (!showPan)
|
|
422
|
+
return;
|
|
423
|
+
if (axis === "pitch" && !supportsPitch)
|
|
424
|
+
return;
|
|
425
|
+
controllerRef.current?.setPanAxis(axis, value);
|
|
426
|
+
},
|
|
427
|
+
nudgeZoom(deltaSign) {
|
|
428
|
+
if (!showZoom)
|
|
429
|
+
return;
|
|
430
|
+
controllerRef.current?.nudgeZoom(deltaSign);
|
|
431
|
+
},
|
|
432
|
+
nudgePan(yawSign, pitchSign) {
|
|
433
|
+
if (!showPan)
|
|
434
|
+
return;
|
|
435
|
+
controllerRef.current?.nudgePan(yawSign, pitchSign);
|
|
436
|
+
},
|
|
437
|
+
}), [stepCamera, showZoom, showPan, supportsPitch]);
|
|
438
|
+
// -------------------------------------------------------------------------
|
|
439
|
+
// Render-size feedback (ResizeObserver, 500 ms debounce, 16:9 crop)
|
|
440
|
+
// -------------------------------------------------------------------------
|
|
441
|
+
const resizeTimerRef = useRef(null);
|
|
442
|
+
useEffect(() => {
|
|
443
|
+
if (renderSize !== "auto" || flightId === null)
|
|
444
|
+
return;
|
|
445
|
+
const el = wrapRef.current;
|
|
446
|
+
if (!el)
|
|
447
|
+
return;
|
|
448
|
+
const cam = client.camera(flightId);
|
|
449
|
+
const observer = new ResizeObserver((entries) => {
|
|
450
|
+
const entry = entries[0];
|
|
451
|
+
if (!entry)
|
|
452
|
+
return;
|
|
453
|
+
const { width } = entry.contentRect;
|
|
454
|
+
if (resizeTimerRef.current !== null)
|
|
455
|
+
clearTimeout(resizeTimerRef.current);
|
|
456
|
+
resizeTimerRef.current = setTimeout(() => {
|
|
457
|
+
const w = toEvenPx(width);
|
|
458
|
+
const h = toEvenPx((width * 9) / 16);
|
|
459
|
+
void cam.setRenderSize(w, h);
|
|
460
|
+
}, 500);
|
|
461
|
+
});
|
|
462
|
+
observer.observe(el);
|
|
463
|
+
return () => {
|
|
464
|
+
observer.disconnect();
|
|
465
|
+
if (resizeTimerRef.current !== null)
|
|
466
|
+
clearTimeout(resizeTimerRef.current);
|
|
467
|
+
};
|
|
468
|
+
}, [client, flightId, renderSize]);
|
|
469
|
+
// -------------------------------------------------------------------------
|
|
470
|
+
// UI state
|
|
471
|
+
// -------------------------------------------------------------------------
|
|
472
|
+
const bitrateLabel = camera && camera.encoderBitrateBps > 0
|
|
473
|
+
? ` · ${Math.round(camera.encoderBitrateBps / 1000)}kbps`
|
|
474
|
+
: "";
|
|
475
|
+
const adaptiveLabel = camera && camera.renderWidth < camera.operatorWidth ? " · adaptive" : "";
|
|
476
|
+
const hasCameras = cameras.length > 0;
|
|
477
|
+
const canStep = cameras.length > 1;
|
|
478
|
+
const menuId = useId();
|
|
479
|
+
const [chromePinned, setChromePinned] = useState(false);
|
|
480
|
+
const cameraMenu = usePortalMenu({
|
|
481
|
+
maxWidth: MENU_MAX_WIDTH,
|
|
482
|
+
maxHeight: MENU_MAX_HEIGHT,
|
|
483
|
+
align: "start",
|
|
484
|
+
});
|
|
485
|
+
const cameraLabel = useMemo(() => buildCameraLabeler(cameras), [cameras]);
|
|
486
|
+
const title = camera ? cameraLabel(camera) : "Camera Feed";
|
|
487
|
+
// -------------------------------------------------------------------------
|
|
488
|
+
// Viewer quality control (opt-in). A built-in action button + menu: Auto
|
|
489
|
+
// plus the resolution presets. Requested state = camera.viewerQuality
|
|
490
|
+
// (authoritative, broadcast by the sidecar so every UI agrees); effective
|
|
491
|
+
// state = renderWidth/Height; qualityLimitedBy marks adaptive throttling.
|
|
492
|
+
// -------------------------------------------------------------------------
|
|
493
|
+
const qualityAvailable = enableQualityControl && flightId !== null && camera !== null;
|
|
494
|
+
const qualityThrottled = Boolean(camera?.qualityLimitedBy);
|
|
495
|
+
const qualityMenuId = useId();
|
|
496
|
+
// Same portal/anchor/dismissal machinery as the camera menu, hung from
|
|
497
|
+
// the right edge of its action-bar trigger.
|
|
498
|
+
const qualityMenu = usePortalMenu({
|
|
499
|
+
maxWidth: QUALITY_MENU_MAX_WIDTH,
|
|
500
|
+
maxHeight: QUALITY_MENU_MAX_HEIGHT,
|
|
501
|
+
align: "end",
|
|
502
|
+
});
|
|
503
|
+
const closeQualityMenu = qualityMenu.close;
|
|
504
|
+
const selectQuality = useCallback((preset) => {
|
|
505
|
+
if (flightId === null)
|
|
506
|
+
return;
|
|
507
|
+
void client.camera(flightId).setQuality(preset);
|
|
508
|
+
closeQualityMenu();
|
|
509
|
+
}, [client, flightId, closeQualityMenu]);
|
|
510
|
+
const topOverlay = (_jsxs(TopOverlay, { children: [_jsxs(TitleRow, { children: [_jsx(TopTitle, { children: hasCameras ? (_jsxs(TitleButton, { ref: cameraMenu.triggerRef, type: "button", "aria-haspopup": "menu", "aria-expanded": cameraMenu.open, "aria-controls": menuId, onClick: cameraMenu.toggle, children: [_jsx(TitleButton__Text, { children: title }), _jsx(ChevronDownIcon, { "aria-hidden": "true" })] })) : (title) }), 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" })] }))] }), cameraMenu.open &&
|
|
511
|
+
hasCameras &&
|
|
512
|
+
cameraMenu.position &&
|
|
513
|
+
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: () => {
|
|
514
|
+
onSelectCamera?.(c.flightId);
|
|
515
|
+
cameraMenu.close();
|
|
516
|
+
}, children: [cameraLabel(c), " (", c.vesselName, ")", isCameraDestroyed(c) ? " - signal lost" : ""] }, c.flightId))) }), document.body), showDebugInfo &&
|
|
517
|
+
(camera ? (_jsxs(TopMeta, { children: [camera.vesselName, " \u00B7 ", camera.renderWidth, "\u00D7", camera.renderHeight, bitrateLabel, adaptiveLabel] })) : (_jsx(TopMeta, { children: "no cameras on this vessel" })))] }));
|
|
518
|
+
const renderAction = (a) => (_jsx(OverlayIconButton, { type: "button", "aria-label": a.label, "aria-pressed": a.active ?? undefined, title: a.label, "$active": a.active ?? false, onClick: a.onClick, children: a.icon }, a.id));
|
|
519
|
+
const builtInActions = flightId !== null && (pipAvailable || fullscreenAvailable || qualityAvailable);
|
|
520
|
+
const hasActionBar = (actions && actions.length > 0) ||
|
|
521
|
+
(trailingActions && trailingActions.length > 0) ||
|
|
522
|
+
builtInActions;
|
|
523
|
+
const actionBar = hasActionBar ? (_jsxs(ActionBar, { children: [actions?.map(renderAction), qualityAvailable && (_jsxs(OverlayIconButton, { ref: qualityMenu.triggerRef, type: "button", "aria-label": "Quality", "aria-haspopup": "menu", "aria-expanded": qualityMenu.open, "aria-controls": qualityMenuId, title: qualityThrottled
|
|
524
|
+
? "Quality (throttled by adaptive performance)"
|
|
525
|
+
: "Quality", "$active": qualityMenu.open, onClick: qualityMenu.toggle, children: [_jsx(QualityIcon, {}), qualityThrottled && _jsx(ThrottledDot, { "aria-hidden": "true" })] })), 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;
|
|
526
|
+
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 &&
|
|
527
|
+
qualityMenu.open &&
|
|
528
|
+
qualityMenu.position &&
|
|
529
|
+
camera &&
|
|
530
|
+
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), isDestroyed && (_jsx(SignalLostOverlay, { role: "status", "aria-label": "Signal lost", children: _jsx(SignalLostText, { "$animated": effectiveShowStatic, children: "SIGNAL LOST" }) })), !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, { children: [_jsx(ZoomButton, { type: "button", "aria-label": "Zoom in", "$pos": "top", onPointerDown: () => controllerRef.current?.setZoomRate(1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
|
|
531
|
+
if (e.detail === 0)
|
|
532
|
+
controllerRef.current?.nudgeZoom(-1);
|
|
533
|
+
}, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, onChange: (e) => {
|
|
534
|
+
const v = Number(e.target.value);
|
|
535
|
+
controllerRef.current?.fovSliderInput(v);
|
|
536
|
+
}, onPointerDown: () => {
|
|
537
|
+
controllerRef.current?.setFovSliderDragging(true);
|
|
538
|
+
}, onPointerUp: () => {
|
|
539
|
+
controllerRef.current?.setFovSliderDragging(false);
|
|
540
|
+
}, onPointerCancel: () => {
|
|
541
|
+
controllerRef.current?.setFovSliderDragging(false);
|
|
542
|
+
} }), _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) => {
|
|
543
|
+
if (e.detail === 0)
|
|
544
|
+
controllerRef.current?.nudgeZoom(1);
|
|
545
|
+
}, 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: {
|
|
546
|
+
transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
|
|
547
|
+
} })] }))] })) }));
|
|
548
|
+
});
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
// Public CameraFeed - wraps inner in a nested provider when client prop given
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
/**
|
|
553
|
+
* Display a live kerbcast camera feed with pan/zoom controls and camera picker.
|
|
554
|
+
* Must be rendered inside a `KerbcastProvider` unless the `client` prop is
|
|
555
|
+
* provided (which creates an implicit inner provider for this feed only).
|
|
556
|
+
*/
|
|
557
|
+
export const CameraFeed = forwardRef(function CameraFeed({ client, ...rest }, ref) {
|
|
558
|
+
if (client) {
|
|
559
|
+
return (_jsx(KerbcastProvider, { client: client, children: _jsx(CameraFeedInner, { ref: ref, ...rest }) }));
|
|
560
|
+
}
|
|
561
|
+
return _jsx(CameraFeedInner, { ref: ref, ...rest });
|
|
562
|
+
});
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// Inline UI primitives (replacing @gonogo/ui dependencies)
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
function ChevronDownIcon(props) {
|
|
567
|
+
return (_jsx("svg", { viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": "true", ...props, children: _jsx("path", { d: "M4 6l4 4 4-4", stroke: "currentColor", strokeWidth: 1.5, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" }) }));
|
|
568
|
+
}
|
|
569
|
+
const iconProps = {
|
|
570
|
+
viewBox: "0 0 16 16",
|
|
571
|
+
width: 14,
|
|
572
|
+
height: 14,
|
|
573
|
+
fill: "none",
|
|
574
|
+
stroke: "currentColor",
|
|
575
|
+
strokeWidth: 1.6,
|
|
576
|
+
strokeLinecap: "round",
|
|
577
|
+
strokeLinejoin: "round",
|
|
578
|
+
"aria-hidden": true,
|
|
579
|
+
};
|
|
580
|
+
function FullscreenEnterIcon() {
|
|
581
|
+
return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M2 6V2.5h3.5M14 6V2.5h-3.5M2 10v3.5h3.5M14 10v3.5h-3.5" }) }));
|
|
582
|
+
}
|
|
583
|
+
function FullscreenExitIcon() {
|
|
584
|
+
return (_jsx("svg", { ...iconProps, children: _jsx("path", { d: "M5.5 2v3.5H2M10.5 2v3.5H14M5.5 14v-3.5H2M10.5 14v-3.5H14" }) }));
|
|
585
|
+
}
|
|
586
|
+
/* Quality control: three horizontal slider rails with offset knobs. */
|
|
587
|
+
function QualityIcon() {
|
|
588
|
+
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" })] }));
|
|
589
|
+
}
|
|
590
|
+
/* Stale badge: fading signal bars (tallest dimmed). */
|
|
591
|
+
function StaleIcon() {
|
|
592
|
+
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" })] }));
|
|
593
|
+
}
|
|
594
|
+
function PictureInPictureIcon() {
|
|
595
|
+
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" })] }));
|
|
596
|
+
}
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
// Styled components
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
const PanControl = styled.div `
|
|
601
|
+
position: absolute;
|
|
602
|
+
bottom: 10px;
|
|
603
|
+
right: 10px;
|
|
604
|
+
width: 52px;
|
|
605
|
+
height: 52px;
|
|
606
|
+
opacity: 0;
|
|
607
|
+
transition: opacity 0.15s;
|
|
608
|
+
touch-action: none;
|
|
609
|
+
|
|
610
|
+
@media (prefers-reduced-motion: reduce) {
|
|
611
|
+
transition: none;
|
|
612
|
+
}
|
|
613
|
+
`;
|
|
614
|
+
const PanArrow = styled.button `
|
|
615
|
+
position: absolute;
|
|
616
|
+
width: 16px;
|
|
617
|
+
height: 16px;
|
|
618
|
+
display: flex;
|
|
619
|
+
align-items: center;
|
|
620
|
+
justify-content: center;
|
|
621
|
+
padding: 0;
|
|
622
|
+
font-size: 11px;
|
|
623
|
+
line-height: 1;
|
|
624
|
+
color: #fff;
|
|
625
|
+
opacity: 0.5;
|
|
626
|
+
background: none;
|
|
627
|
+
border: none;
|
|
628
|
+
cursor: pointer;
|
|
629
|
+
touch-action: none;
|
|
630
|
+
text-shadow:
|
|
631
|
+
0 0 3px rgba(0, 0, 0, 0.9),
|
|
632
|
+
0 1px 2px rgba(0, 0, 0, 0.8);
|
|
633
|
+
|
|
634
|
+
${(p) => p.$dir === "up"
|
|
635
|
+
? "top: 0; left: 50%; transform: translateX(-50%);"
|
|
636
|
+
: p.$dir === "down"
|
|
637
|
+
? "bottom: 0; left: 50%; transform: translateX(-50%);"
|
|
638
|
+
: p.$dir === "left"
|
|
639
|
+
? "left: 0; top: 50%; transform: translateY(-50%);"
|
|
640
|
+
: "right: 0; top: 50%; transform: translateY(-50%);"}
|
|
641
|
+
|
|
642
|
+
@media (hover: hover) {
|
|
643
|
+
&:hover:not(:disabled) {
|
|
644
|
+
opacity: 1;
|
|
645
|
+
color: var(--kerbcast-accent, #00ff88);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
&:disabled {
|
|
649
|
+
opacity: 0.3;
|
|
650
|
+
cursor: default;
|
|
651
|
+
}
|
|
652
|
+
&:focus-visible {
|
|
653
|
+
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
654
|
+
outline-offset: 2px;
|
|
655
|
+
}
|
|
656
|
+
`;
|
|
657
|
+
const PanBall = styled.div `
|
|
658
|
+
position: absolute;
|
|
659
|
+
top: 50%;
|
|
660
|
+
left: 50%;
|
|
661
|
+
width: 12px;
|
|
662
|
+
height: 12px;
|
|
663
|
+
margin: -6px 0 0 -6px;
|
|
664
|
+
border-radius: 50%;
|
|
665
|
+
background: radial-gradient(circle at 35% 30%, #ffffff, #d6dbe1);
|
|
666
|
+
box-shadow:
|
|
667
|
+
0 0 0 1px rgba(0, 0, 0, 0.5),
|
|
668
|
+
0 0 4px rgba(255, 255, 255, 0.4);
|
|
669
|
+
cursor: grab;
|
|
670
|
+
touch-action: none;
|
|
671
|
+
|
|
672
|
+
&:active {
|
|
673
|
+
cursor: grabbing;
|
|
674
|
+
}
|
|
675
|
+
`;
|
|
676
|
+
const ZoomControlsWrap = styled.div `
|
|
677
|
+
position: absolute;
|
|
678
|
+
bottom: 8px;
|
|
679
|
+
left: 8px;
|
|
680
|
+
width: 30px;
|
|
681
|
+
display: flex;
|
|
682
|
+
flex-direction: column;
|
|
683
|
+
align-items: stretch;
|
|
684
|
+
background: rgba(0, 0, 0, 0.6);
|
|
685
|
+
border: 1px solid rgba(255, 255, 255, 0.5);
|
|
686
|
+
opacity: 0;
|
|
687
|
+
transition: opacity 0.15s;
|
|
688
|
+
|
|
689
|
+
@media (prefers-reduced-motion: reduce) {
|
|
690
|
+
transition: none;
|
|
691
|
+
}
|
|
692
|
+
`;
|
|
693
|
+
const ZoomButton = styled.button `
|
|
694
|
+
width: 100%;
|
|
695
|
+
height: 26px;
|
|
696
|
+
display: flex;
|
|
697
|
+
align-items: center;
|
|
698
|
+
justify-content: center;
|
|
699
|
+
padding: 0;
|
|
700
|
+
background: transparent;
|
|
701
|
+
border: none;
|
|
702
|
+
border-radius: 0;
|
|
703
|
+
color: #fff;
|
|
704
|
+
font-size: 1rem;
|
|
705
|
+
cursor: pointer;
|
|
706
|
+
${(p) => p.$pos === "top"
|
|
707
|
+
? "border-bottom: 1px solid rgba(255, 255, 255, 0.3);"
|
|
708
|
+
: "border-top: 1px solid rgba(255, 255, 255, 0.3);"}
|
|
709
|
+
|
|
710
|
+
@media (hover: hover) {
|
|
711
|
+
&:hover {
|
|
712
|
+
color: #fff;
|
|
713
|
+
background: rgba(255, 255, 255, 0.15);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
&:focus-visible {
|
|
718
|
+
outline: 2px solid #fff;
|
|
719
|
+
outline-offset: -2px;
|
|
720
|
+
}
|
|
721
|
+
`;
|
|
722
|
+
const TopOverlay = styled.div `
|
|
723
|
+
position: absolute;
|
|
724
|
+
top: 0;
|
|
725
|
+
left: 0;
|
|
726
|
+
right: 0;
|
|
727
|
+
z-index: 2;
|
|
728
|
+
display: flex;
|
|
729
|
+
flex-direction: column;
|
|
730
|
+
gap: 3px;
|
|
731
|
+
padding: 6px 8px 14px;
|
|
732
|
+
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.78), rgba(0, 0, 0, 0));
|
|
733
|
+
opacity: 0;
|
|
734
|
+
pointer-events: none;
|
|
735
|
+
transition: opacity 0.15s;
|
|
736
|
+
|
|
737
|
+
@media (prefers-reduced-motion: reduce) {
|
|
738
|
+
transition: none;
|
|
739
|
+
}
|
|
740
|
+
`;
|
|
741
|
+
const TitleRow = styled.div `
|
|
742
|
+
display: flex;
|
|
743
|
+
align-items: flex-start;
|
|
744
|
+
justify-content: space-between;
|
|
745
|
+
gap: 8px;
|
|
746
|
+
`;
|
|
747
|
+
const TopTitle = styled.h3 `
|
|
748
|
+
margin: 0;
|
|
749
|
+
min-width: 0;
|
|
750
|
+
font-size: var(--font-size-xs, 11px);
|
|
751
|
+
font-weight: 600;
|
|
752
|
+
letter-spacing: 0.12em;
|
|
753
|
+
text-transform: uppercase;
|
|
754
|
+
color: #fff;
|
|
755
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
756
|
+
`;
|
|
757
|
+
const TitleButton = styled.button `
|
|
758
|
+
display: inline-flex;
|
|
759
|
+
align-items: center;
|
|
760
|
+
gap: 4px;
|
|
761
|
+
max-width: 100%;
|
|
762
|
+
margin: 0;
|
|
763
|
+
padding: 0;
|
|
764
|
+
background: none;
|
|
765
|
+
border: none;
|
|
766
|
+
cursor: pointer;
|
|
767
|
+
color: inherit;
|
|
768
|
+
font: inherit;
|
|
769
|
+
letter-spacing: inherit;
|
|
770
|
+
text-transform: inherit;
|
|
771
|
+
text-shadow: inherit;
|
|
772
|
+
text-align: left;
|
|
773
|
+
|
|
774
|
+
svg {
|
|
775
|
+
width: 12px;
|
|
776
|
+
height: 12px;
|
|
777
|
+
flex-shrink: 0;
|
|
778
|
+
transition: transform 0.15s;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
&[aria-expanded="true"] svg {
|
|
782
|
+
transform: rotate(180deg);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
@media (prefers-reduced-motion: reduce) {
|
|
786
|
+
svg {
|
|
787
|
+
transition: none;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
&:focus-visible {
|
|
792
|
+
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
793
|
+
outline-offset: 2px;
|
|
794
|
+
}
|
|
795
|
+
`;
|
|
796
|
+
const TitleButton__Text = styled.span `
|
|
797
|
+
overflow: hidden;
|
|
798
|
+
text-overflow: ellipsis;
|
|
799
|
+
white-space: nowrap;
|
|
800
|
+
`;
|
|
801
|
+
const StepButtons = styled.div `
|
|
802
|
+
display: flex;
|
|
803
|
+
align-items: center;
|
|
804
|
+
gap: 4px;
|
|
805
|
+
flex-shrink: 0;
|
|
806
|
+
`;
|
|
807
|
+
const CameraMenu = styled.div `
|
|
808
|
+
/* Portaled to document.body: fixed position (set inline from the trigger
|
|
809
|
+
rect) keeps it clear of the tile's overflow clipping. */
|
|
810
|
+
position: fixed;
|
|
811
|
+
z-index: 1000;
|
|
812
|
+
max-width: min(260px, calc(100vw - 16px));
|
|
813
|
+
/* Cap the list so a long camera roster scrolls instead of spilling past
|
|
814
|
+
the tile/viewport. 40vh keeps it sane on short windows; 300px on tall. */
|
|
815
|
+
max-height: min(40vh, 300px);
|
|
816
|
+
display: flex;
|
|
817
|
+
flex-direction: column;
|
|
818
|
+
background: rgba(0, 0, 0, 0.85);
|
|
819
|
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
820
|
+
border-radius: 4px;
|
|
821
|
+
overflow-x: hidden;
|
|
822
|
+
overflow-y: auto;
|
|
823
|
+
`;
|
|
824
|
+
const CameraMenuItem = styled.button `
|
|
825
|
+
display: block;
|
|
826
|
+
width: 100%;
|
|
827
|
+
flex-shrink: 0;
|
|
828
|
+
padding: 6px 8px;
|
|
829
|
+
text-align: left;
|
|
830
|
+
background: ${(p) => p.$selected
|
|
831
|
+
? "var(--kerbcast-accent-wash, rgba(0, 255, 136, 0.15))"
|
|
832
|
+
: "transparent"};
|
|
833
|
+
border: none;
|
|
834
|
+
cursor: pointer;
|
|
835
|
+
color: #fff;
|
|
836
|
+
font-size: 11px;
|
|
837
|
+
letter-spacing: 0.04em;
|
|
838
|
+
|
|
839
|
+
@media (hover: hover) {
|
|
840
|
+
&:hover {
|
|
841
|
+
background: rgba(255, 255, 255, 0.15);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
&:focus-visible {
|
|
846
|
+
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
847
|
+
outline-offset: -2px;
|
|
848
|
+
}
|
|
849
|
+
`;
|
|
850
|
+
const TopMeta = styled.div `
|
|
851
|
+
font-size: 11px;
|
|
852
|
+
letter-spacing: 0.04em;
|
|
853
|
+
color: rgba(255, 255, 255, 0.78);
|
|
854
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
855
|
+
`;
|
|
856
|
+
/* The quality picker: portaled to document.body like CameraMenu, hung from
|
|
857
|
+
its action-bar trigger (fixed position set inline from the trigger rect)
|
|
858
|
+
so tile clipping cannot cut it off and it survives the hover-revealed
|
|
859
|
+
chrome fading while the pointer is over the menu. */
|
|
860
|
+
const QualityMenu = styled.div `
|
|
861
|
+
position: fixed;
|
|
862
|
+
z-index: 1000;
|
|
863
|
+
min-width: 140px;
|
|
864
|
+
max-width: min(220px, calc(100vw - 16px));
|
|
865
|
+
max-height: min(40vh, 220px);
|
|
866
|
+
display: flex;
|
|
867
|
+
flex-direction: column;
|
|
868
|
+
background: rgba(0, 0, 0, 0.85);
|
|
869
|
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
870
|
+
border-radius: 4px;
|
|
871
|
+
overflow-x: hidden;
|
|
872
|
+
overflow-y: auto;
|
|
873
|
+
`;
|
|
874
|
+
/* Effective-state footer of the quality menu: what the camera is actually
|
|
875
|
+
rendering right now, with a "throttled" marker while the adaptive
|
|
876
|
+
machinery holds it below the request. */
|
|
877
|
+
const QualityMeta = styled.div `
|
|
878
|
+
padding: 5px 8px;
|
|
879
|
+
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
|
880
|
+
font-size: 10px;
|
|
881
|
+
letter-spacing: 0.04em;
|
|
882
|
+
color: rgba(255, 255, 255, 0.65);
|
|
883
|
+
`;
|
|
884
|
+
/* Throttled marker on the quality action button. */
|
|
885
|
+
const ThrottledDot = styled.span `
|
|
886
|
+
position: absolute;
|
|
887
|
+
top: -3px;
|
|
888
|
+
right: -3px;
|
|
889
|
+
width: 7px;
|
|
890
|
+
height: 7px;
|
|
891
|
+
border-radius: 50%;
|
|
892
|
+
background: #ffb347;
|
|
893
|
+
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6);
|
|
894
|
+
`;
|
|
895
|
+
/* Top-right cluster of overlay controls (custom actions + fullscreen/PiP). */
|
|
896
|
+
const ActionBar = styled.div `
|
|
897
|
+
position: absolute;
|
|
898
|
+
top: 6px;
|
|
899
|
+
right: 8px;
|
|
900
|
+
z-index: 3;
|
|
901
|
+
display: flex;
|
|
902
|
+
align-items: center;
|
|
903
|
+
gap: 4px;
|
|
904
|
+
opacity: 0;
|
|
905
|
+
pointer-events: none;
|
|
906
|
+
transition: opacity 0.15s;
|
|
907
|
+
|
|
908
|
+
@media (prefers-reduced-motion: reduce) {
|
|
909
|
+
transition: none;
|
|
910
|
+
}
|
|
911
|
+
`;
|
|
912
|
+
/*
|
|
913
|
+
* Outer frame. Fills the host container: the video is absolutely
|
|
914
|
+
* positioned, so without explicit width/height the Stage collapses
|
|
915
|
+
* to zero height.
|
|
916
|
+
*/
|
|
917
|
+
const Panel = styled.div `
|
|
918
|
+
display: flex;
|
|
919
|
+
flex-direction: column;
|
|
920
|
+
width: 100%;
|
|
921
|
+
height: 100%;
|
|
922
|
+
box-sizing: border-box;
|
|
923
|
+
background: #111;
|
|
924
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
925
|
+
border-radius: 4px;
|
|
926
|
+
overflow: hidden;
|
|
927
|
+
`;
|
|
928
|
+
const Stage = styled(Panel) `
|
|
929
|
+
padding: 0;
|
|
930
|
+
gap: 0;
|
|
931
|
+
position: relative;
|
|
932
|
+
background: #000;
|
|
933
|
+
align-items: center;
|
|
934
|
+
justify-content: center;
|
|
935
|
+
|
|
936
|
+
&:hover ${TopOverlay},
|
|
937
|
+
&:focus-within ${TopOverlay},
|
|
938
|
+
&:hover ${ZoomControlsWrap},
|
|
939
|
+
&:focus-within ${ZoomControlsWrap},
|
|
940
|
+
&:hover ${PanControl},
|
|
941
|
+
&:focus-within ${PanControl},
|
|
942
|
+
&:hover ${ActionBar},
|
|
943
|
+
&:focus-within ${ActionBar} {
|
|
944
|
+
opacity: 1;
|
|
945
|
+
}
|
|
946
|
+
&:hover ${TopOverlay},
|
|
947
|
+
&:focus-within ${TopOverlay},
|
|
948
|
+
&:hover ${ActionBar},
|
|
949
|
+
&:focus-within ${ActionBar} {
|
|
950
|
+
pointer-events: auto;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
${(p) => p.$pinned &&
|
|
954
|
+
css `
|
|
955
|
+
${TopOverlay} {
|
|
956
|
+
opacity: 1;
|
|
957
|
+
pointer-events: auto;
|
|
958
|
+
}
|
|
959
|
+
${ActionBar} {
|
|
960
|
+
opacity: 1;
|
|
961
|
+
pointer-events: auto;
|
|
962
|
+
}
|
|
963
|
+
${ZoomControlsWrap},
|
|
964
|
+
${PanControl} {
|
|
965
|
+
opacity: 1;
|
|
966
|
+
}
|
|
967
|
+
`}
|
|
968
|
+
`;
|
|
969
|
+
const Empty = styled.div `
|
|
970
|
+
color: #888;
|
|
971
|
+
font-size: 13px;
|
|
972
|
+
font-style: italic;
|
|
973
|
+
padding: 1rem;
|
|
974
|
+
text-align: center;
|
|
975
|
+
`;
|
|
976
|
+
const OverlayIconButton = styled.button `
|
|
977
|
+
position: relative;
|
|
978
|
+
width: 24px;
|
|
979
|
+
height: 24px;
|
|
980
|
+
display: flex;
|
|
981
|
+
align-items: center;
|
|
982
|
+
justify-content: center;
|
|
983
|
+
padding: 0;
|
|
984
|
+
background: ${(p) => p.$active
|
|
985
|
+
? "var(--kerbcast-accent, #00ff88)"
|
|
986
|
+
: "rgba(0, 0, 0, 0.5)"};
|
|
987
|
+
border: 1px solid
|
|
988
|
+
${(p) => p.$active
|
|
989
|
+
? "var(--kerbcast-accent, #00ff88)"
|
|
990
|
+
: "rgba(255, 255, 255, 0.3)"};
|
|
991
|
+
border-radius: 3px;
|
|
992
|
+
color: ${(p) => (p.$active ? "#000" : "#fff")};
|
|
993
|
+
cursor: pointer;
|
|
994
|
+
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
|
995
|
+
|
|
996
|
+
@media (hover: hover) {
|
|
997
|
+
&:hover {
|
|
998
|
+
background: ${(p) => p.$active
|
|
999
|
+
? "var(--kerbcast-accent, #00ff88)"
|
|
1000
|
+
: "rgba(0, 0, 0, 0.7)"};
|
|
1001
|
+
border-color: rgba(255, 255, 255, 0.6);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
&:focus-visible {
|
|
1006
|
+
outline: 2px solid var(--kerbcast-accent, #00ff88);
|
|
1007
|
+
outline-offset: 2px;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
&:disabled {
|
|
1011
|
+
opacity: 0.4;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1015
|
+
transition: none;
|
|
1016
|
+
}
|
|
1017
|
+
`;
|
|
1018
|
+
const StyledVideo = styled.video `
|
|
1019
|
+
position: absolute;
|
|
1020
|
+
inset: 0;
|
|
1021
|
+
width: 100%;
|
|
1022
|
+
height: 100%;
|
|
1023
|
+
object-fit: contain;
|
|
1024
|
+
`;
|
|
1025
|
+
/**
|
|
1026
|
+
* Shown when the sidecar reports `lifecycle: "destroyed"`. The kerbcast SDK
|
|
1027
|
+
* keeps the camera's noise pipeline alive on the same `mediaStream`, so the
|
|
1028
|
+
* video behind this overlay shows live signal-loss static.
|
|
1029
|
+
*/
|
|
1030
|
+
const SignalLostOverlay = styled.div `
|
|
1031
|
+
position: absolute;
|
|
1032
|
+
inset: 0;
|
|
1033
|
+
display: flex;
|
|
1034
|
+
align-items: center;
|
|
1035
|
+
justify-content: center;
|
|
1036
|
+
background: rgba(0, 0, 0, 0.25);
|
|
1037
|
+
`;
|
|
1038
|
+
const SignalLostText = styled.span `
|
|
1039
|
+
color: #ff4444;
|
|
1040
|
+
font-size: 1.1rem;
|
|
1041
|
+
font-weight: 700;
|
|
1042
|
+
letter-spacing: 0.15em;
|
|
1043
|
+
text-transform: uppercase;
|
|
1044
|
+
text-shadow:
|
|
1045
|
+
0 0 8px rgba(255, 68, 68, 0.7),
|
|
1046
|
+
0 1px 2px rgba(0, 0, 0, 0.9);
|
|
1047
|
+
|
|
1048
|
+
${(p) => p.$animated &&
|
|
1049
|
+
`
|
|
1050
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
1051
|
+
animation: signal-lost-pulse 2s ease-in-out infinite;
|
|
1052
|
+
}
|
|
1053
|
+
`}
|
|
1054
|
+
|
|
1055
|
+
@keyframes signal-lost-pulse {
|
|
1056
|
+
0%,
|
|
1057
|
+
100% {
|
|
1058
|
+
opacity: 1;
|
|
1059
|
+
}
|
|
1060
|
+
50% {
|
|
1061
|
+
opacity: 0.6;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
`;
|
|
1065
|
+
/*
|
|
1066
|
+
* No-static stall presentation (`showStatic={false}`): a subtle dim over
|
|
1067
|
+
* the frozen last frame plus a corner badge, so a frozen frame is never
|
|
1068
|
+
* mistakable for a live one. Always visible (not hover chrome).
|
|
1069
|
+
*/
|
|
1070
|
+
const StaleScrim = styled.div `
|
|
1071
|
+
position: absolute;
|
|
1072
|
+
inset: 0;
|
|
1073
|
+
z-index: 1;
|
|
1074
|
+
background: rgba(0, 0, 0, 0.32);
|
|
1075
|
+
pointer-events: none;
|
|
1076
|
+
`;
|
|
1077
|
+
const StaleBadge = styled.div `
|
|
1078
|
+
position: absolute;
|
|
1079
|
+
bottom: 8px;
|
|
1080
|
+
right: 8px;
|
|
1081
|
+
z-index: 2;
|
|
1082
|
+
display: flex;
|
|
1083
|
+
align-items: center;
|
|
1084
|
+
gap: 4px;
|
|
1085
|
+
padding: 2px 6px;
|
|
1086
|
+
background: rgba(0, 0, 0, 0.6);
|
|
1087
|
+
border: 1px solid rgba(255, 179, 71, 0.6);
|
|
1088
|
+
border-radius: 3px;
|
|
1089
|
+
color: #ffb347;
|
|
1090
|
+
font-size: 10px;
|
|
1091
|
+
font-weight: 600;
|
|
1092
|
+
letter-spacing: 0.12em;
|
|
1093
|
+
text-transform: uppercase;
|
|
1094
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
1095
|
+
pointer-events: none;
|
|
1096
|
+
`;
|
|
1097
|
+
const FovSlider = styled.input `
|
|
1098
|
+
writing-mode: vertical-lr;
|
|
1099
|
+
width: 100%;
|
|
1100
|
+
height: 54px;
|
|
1101
|
+
margin: 0;
|
|
1102
|
+
padding: 3px 0;
|
|
1103
|
+
cursor: pointer;
|
|
1104
|
+
accent-color: #fff;
|
|
1105
|
+
|
|
1106
|
+
&:focus-visible {
|
|
1107
|
+
outline: 2px solid #fff;
|
|
1108
|
+
outline-offset: -2px;
|
|
1109
|
+
}
|
|
1110
|
+
`;
|