@limrun/ui 0.15.0 → 0.16.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/README.md +27 -0
- package/dist/components/remote-control-framed.d.ts +2 -0
- package/dist/components/remote-control.d.ts +28 -71
- package/dist/core/ax-fetcher.d.ts +5 -0
- package/dist/core/camera.d.ts +58 -0
- package/dist/frame-assets.d.ts +2 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +25 -2447
- package/dist/lite.cjs +1 -0
- package/dist/lite.d.ts +6 -0
- package/dist/lite.js +13 -0
- package/dist/remote-control-BRgbD7yo.mjs +2516 -0
- package/dist/remote-control-VISrP1zI.js +1 -0
- package/package.json +11 -4
- package/src/components/remote-control-framed.tsx +14 -0
- package/src/components/remote-control.tsx +369 -435
- package/src/core/ax-fetcher.ts +15 -5
- package/src/core/camera.test.ts +155 -0
- package/src/core/camera.ts +133 -0
- package/src/demo.tsx +2 -1
- package/src/frame-assets.ts +22 -0
- package/src/index.ts +13 -3
- package/src/lite.test.ts +30 -0
- package/src/lite.ts +30 -0
- package/vite.config.ts +5 -0
|
@@ -4,31 +4,34 @@ import './remote-control.css';
|
|
|
4
4
|
|
|
5
5
|
import { ANDROID_KEYS, AMOTION_EVENT, codeMap } from '../core/constants';
|
|
6
6
|
|
|
7
|
-
import iphoneFrameImage from '../assets/iphone16pro_black_bg.webp';
|
|
8
|
-
import pixelFrameImage from '../assets/pixel9_black.webp';
|
|
9
|
-
import pixelFrameImageLandscape from '../assets/pixel9_black_landscape.webp';
|
|
10
|
-
import pixelTabletFrameImage from '../assets/pixel_tablet_portrait.webp';
|
|
11
|
-
import pixelTabletFrameImageLandscape from '../assets/pixel_tablet_landscape.webp';
|
|
12
|
-
import iphoneFrameImageLandscape from '../assets/iphone16pro_black_landscape_bg.webp';
|
|
13
|
-
import appleLogoSvg from '../assets/Apple_logo_white.svg';
|
|
14
|
-
import androidBootImage from '../assets/android_boot.webp';
|
|
15
7
|
import {
|
|
16
8
|
createTouchControlMessage,
|
|
17
9
|
createInjectKeycodeMessage,
|
|
18
10
|
createSetClipboardMessage,
|
|
19
11
|
createTwoFingerTouchControlMessage,
|
|
20
12
|
} from '../core/webrtc-messages';
|
|
21
|
-
import { AxFetcher, AxStatus } from '../core/ax-fetcher';
|
|
13
|
+
import { AxFetcher, AxStatus, type AndroidElementTreeOptions } from '../core/ax-fetcher';
|
|
22
14
|
import { AxElement, AxSnapshot, axElementAtPoint, axSnapshotsEqual } from '../core/ax-tree';
|
|
15
|
+
import {
|
|
16
|
+
CameraOperationCoordinator,
|
|
17
|
+
cameraCaptureConstraints,
|
|
18
|
+
createGrantedCameraResult,
|
|
19
|
+
parseCameraRequest,
|
|
20
|
+
shouldReacquireCamera,
|
|
21
|
+
type CameraCaptureInfo,
|
|
22
|
+
type CameraRequest,
|
|
23
|
+
} from '../core/camera';
|
|
23
24
|
import { InspectOverlay, InspectOverlayGeometry, InspectMode } from './inspect-overlay';
|
|
24
25
|
|
|
26
|
+
export type { CameraCaptureInfo, CameraFacingMode, CameraRequest, CameraResult } from '../core/camera';
|
|
27
|
+
|
|
25
28
|
declare global {
|
|
26
29
|
interface Window {
|
|
27
30
|
debugRemoteControl?: boolean;
|
|
28
31
|
}
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
interface RemoteControlProps {
|
|
34
|
+
export interface RemoteControlProps {
|
|
32
35
|
// url is the URL of the instance to connect to.
|
|
33
36
|
url: string;
|
|
34
37
|
|
|
@@ -71,6 +74,15 @@ interface RemoteControlProps {
|
|
|
71
74
|
* etc.). It is not called for ordinary network drops.
|
|
72
75
|
*/
|
|
73
76
|
onTerminated?: () => void;
|
|
77
|
+
/**
|
|
78
|
+
* Device frame and boot-logo images. The default export bundles Limrun's
|
|
79
|
+
* frames; `@limrun/ui/lite` omits them so a frameless embed stays small.
|
|
80
|
+
*/
|
|
81
|
+
assets?: RemoteControlAssets;
|
|
82
|
+
/**
|
|
83
|
+
* Coarse connection lifecycle, for status UI outside the component.
|
|
84
|
+
*/
|
|
85
|
+
onConnectionStateChange?: (state: RemoteControlConnectionState) => void;
|
|
74
86
|
|
|
75
87
|
/**
|
|
76
88
|
* Enable the inspect overlay. When set, the component starts polling the
|
|
@@ -154,6 +166,8 @@ interface RemoteControlProps {
|
|
|
154
166
|
*/
|
|
155
167
|
axMaxBackoffMs?: number;
|
|
156
168
|
|
|
169
|
+
androidElementTreeOptions?: AndroidElementTreeOptions;
|
|
170
|
+
|
|
157
171
|
/**
|
|
158
172
|
* Fires whenever the iOS simulator's camera demand state changes —
|
|
159
173
|
* i.e. an app inside the sim called
|
|
@@ -163,12 +177,10 @@ interface RemoteControlProps {
|
|
|
163
177
|
* UI can render a status indicator ("simulator is using your
|
|
164
178
|
* camera", etc.).
|
|
165
179
|
*
|
|
166
|
-
* `active` reflects whether the sim is currently asking for
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* (in which case the limulator side switches to a black-frame
|
|
171
|
-
* fallback so the app keeps ticking).
|
|
180
|
+
* `active` reflects whether the sim is currently asking for frames.
|
|
181
|
+
* `granted` is set on capture acknowledgements: `true` after a new
|
|
182
|
+
* track, reused track, or constraint update; `false` when capture
|
|
183
|
+
* fails. The host switches to black frames after a failure.
|
|
172
184
|
*
|
|
173
185
|
* `camera` (optional, only meaningful when `granted === true`)
|
|
174
186
|
* carries what `MediaStreamTrack.getSettings()` reported for the
|
|
@@ -200,41 +212,6 @@ interface RemoteControlProps {
|
|
|
200
212
|
*/
|
|
201
213
|
onCameraStats?: (stats: CameraStreamStats | null) => void;
|
|
202
214
|
|
|
203
|
-
/**
|
|
204
|
-
* Optional resolution cap for outbound camera capture.
|
|
205
|
-
*
|
|
206
|
-
* - `'auto'` (default): no extra constraint. The browser captures
|
|
207
|
-
* at its webcam's native max; WebRTC's quality scaler may step
|
|
208
|
-
* down resolution on the encoder side under bandwidth pressure
|
|
209
|
-
* while still feeding the simulator at the pool's native size.
|
|
210
|
-
* - `'1080p'` / `'720p'` / `'480p'`: hard cap applied via
|
|
211
|
-
* `getUserMedia` constraints (for new captures) and
|
|
212
|
-
* `track.applyConstraints` (for the currently-active track),
|
|
213
|
-
* matching the way Meet/Zoom expose a "Send resolution" picker.
|
|
214
|
-
*
|
|
215
|
-
* Bumping or lowering the cap mid-stream is supported; the
|
|
216
|
-
* change takes effect within a frame or two as the webcam
|
|
217
|
-
* re-negotiates.
|
|
218
|
-
*/
|
|
219
|
-
cameraResolutionCap?: CameraResolutionCap;
|
|
220
|
-
/**
|
|
221
|
-
* Aspect ratio the simulator's virtual camera should report to apps.
|
|
222
|
-
* Picking a value here triggers a `cameraAspect` WS message to the
|
|
223
|
-
* host, which rebuilds its IOSurface ring at the matching dimensions
|
|
224
|
-
* (16:9 → 1920×1080, 4:3 → 1440×1080, 1:1 → 1080×1080, 9:16 →
|
|
225
|
-
* 1080×1920) and signals the in-sim dylib to re-handshake. iOS apps
|
|
226
|
-
* see CMSampleBuffers at the new dimensions within a frame or two.
|
|
227
|
-
*
|
|
228
|
-
* The browser still captures whatever the webcam offers; the host
|
|
229
|
-
* aspect-fills (cover, center-crop) into the new pool. Switching
|
|
230
|
-
* aspect at runtime is intentionally cheap so users can A/B preview
|
|
231
|
-
* styles without restarting the simulator.
|
|
232
|
-
*
|
|
233
|
-
* `undefined` leaves the pool untouched (the host's boot default —
|
|
234
|
-
* 16:9 / 1920×1080 — applies).
|
|
235
|
-
*/
|
|
236
|
-
cameraAspect?: CameraAspect;
|
|
237
|
-
|
|
238
215
|
/**
|
|
239
216
|
* When true, capture the user's microphone via
|
|
240
217
|
* `getUserMedia({ audio: true })` and stream it live into the
|
|
@@ -273,37 +250,6 @@ export interface MicrophoneState {
|
|
|
273
250
|
label?: string;
|
|
274
251
|
}
|
|
275
252
|
|
|
276
|
-
/**
|
|
277
|
-
* Resolution caps a host app can request on the outbound camera.
|
|
278
|
-
* `'auto'` is "let the browser decide" (no constraints beyond the
|
|
279
|
-
* 30 fps ceiling); the other options clamp width/height to the
|
|
280
|
-
* named target. Aspect ratio is preserved.
|
|
281
|
-
*/
|
|
282
|
-
export type CameraResolutionCap = 'auto' | '1080p' | '720p' | '480p';
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Aspect ratios exposed to the operator for the simulated camera.
|
|
286
|
-
* The host maps each label to concrete IOSurface dimensions; values
|
|
287
|
-
* the host doesn't recognise are silently ignored.
|
|
288
|
-
*/
|
|
289
|
-
export type CameraAspect = '16:9' | '4:3' | '1:1' | '9:16';
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Snapshot of the browser's webcam capture, mirrored from
|
|
293
|
-
* `MediaStreamTrack.getSettings()`. Forwarded to the host alongside
|
|
294
|
-
* `cameraResult` and exposed to the host app via
|
|
295
|
-
* `onCameraDemandChange` so it can render a status indicator without
|
|
296
|
-
* having to call `getStats()` itself.
|
|
297
|
-
*/
|
|
298
|
-
export interface CameraCaptureInfo {
|
|
299
|
-
width?: number;
|
|
300
|
-
height?: number;
|
|
301
|
-
frameRate?: number;
|
|
302
|
-
deviceId?: string;
|
|
303
|
-
label?: string;
|
|
304
|
-
facingMode?: string;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
253
|
/**
|
|
308
254
|
* Live outbound-camera quality snapshot. Derived from
|
|
309
255
|
* `RTCPeerConnection.getStats()` and rate-derived deltas, sampled
|
|
@@ -443,18 +389,30 @@ export const withAuthenticationToken = (url: string, token: string): string => {
|
|
|
443
389
|
return endpoint.toString();
|
|
444
390
|
};
|
|
445
391
|
|
|
392
|
+
export type RemoteControlConnectionState =
|
|
393
|
+
| 'connecting'
|
|
394
|
+
| 'connected'
|
|
395
|
+
| 'reconnecting'
|
|
396
|
+
| 'failed'
|
|
397
|
+
| 'terminated';
|
|
398
|
+
|
|
399
|
+
export type RemoteControlPlatformAssets = {
|
|
400
|
+
frame?: string;
|
|
401
|
+
frameLandscape?: string;
|
|
402
|
+
tabletFrame?: string;
|
|
403
|
+
tabletFrameLandscape?: string;
|
|
404
|
+
loadingLogo?: string;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
export type RemoteControlAssets = Partial<Record<DevicePlatform, RemoteControlPlatformAssets>>;
|
|
408
|
+
|
|
446
409
|
type DeviceConfig = {
|
|
447
410
|
videoBorderRadiusMultiplier: number;
|
|
448
|
-
loadingLogo: string;
|
|
449
411
|
loadingLogoSize: string;
|
|
450
412
|
videoPosition: {
|
|
451
413
|
portrait: { heightMultiplier?: number; widthMultiplier?: number };
|
|
452
414
|
landscape: { heightMultiplier?: number; widthMultiplier?: number };
|
|
453
415
|
};
|
|
454
|
-
frame: {
|
|
455
|
-
image: string;
|
|
456
|
-
imageLandscape: string;
|
|
457
|
-
};
|
|
458
416
|
};
|
|
459
417
|
|
|
460
418
|
const ANDROID_TABLET_VIDEO_WIDTH = 1920;
|
|
@@ -475,12 +433,7 @@ const isAndroidTabletVideo = (width: number, height: number): boolean =>
|
|
|
475
433
|
// Video position percentages are relative to the frame image dimensions
|
|
476
434
|
const deviceConfig: Record<DevicePlatform, DeviceConfig> = {
|
|
477
435
|
ios: {
|
|
478
|
-
frame: {
|
|
479
|
-
image: iphoneFrameImage,
|
|
480
|
-
imageLandscape: iphoneFrameImageLandscape,
|
|
481
|
-
},
|
|
482
436
|
videoBorderRadiusMultiplier: 0.15,
|
|
483
|
-
loadingLogo: appleLogoSvg,
|
|
484
437
|
loadingLogoSize: '20%',
|
|
485
438
|
// Video position as percentage of frame dimensions
|
|
486
439
|
videoPosition: {
|
|
@@ -489,12 +442,7 @@ const deviceConfig: Record<DevicePlatform, DeviceConfig> = {
|
|
|
489
442
|
},
|
|
490
443
|
},
|
|
491
444
|
android: {
|
|
492
|
-
frame: {
|
|
493
|
-
image: pixelFrameImage,
|
|
494
|
-
imageLandscape: pixelFrameImageLandscape,
|
|
495
|
-
},
|
|
496
445
|
videoBorderRadiusMultiplier: 0.13,
|
|
497
|
-
loadingLogo: androidBootImage,
|
|
498
446
|
loadingLogoSize: '40%',
|
|
499
447
|
// Video position as percentage of frame dimensions
|
|
500
448
|
videoPosition: {
|
|
@@ -545,16 +493,17 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
545
493
|
showFrame = true,
|
|
546
494
|
autoReconnect = false,
|
|
547
495
|
onTerminated,
|
|
496
|
+
assets,
|
|
497
|
+
onConnectionStateChange,
|
|
548
498
|
inspectMode,
|
|
549
499
|
onAxSnapshotChange,
|
|
550
500
|
onInspectSelectionChange,
|
|
551
501
|
onAxStatusChange,
|
|
552
502
|
axPollIntervalMs,
|
|
553
503
|
axMaxBackoffMs,
|
|
504
|
+
androidElementTreeOptions,
|
|
554
505
|
onCameraDemandChange,
|
|
555
506
|
onCameraStats,
|
|
556
|
-
cameraResolutionCap = 'auto',
|
|
557
|
-
cameraAspect,
|
|
558
507
|
microphoneEnabled = false,
|
|
559
508
|
onMicrophoneStateChange,
|
|
560
509
|
}: RemoteControlProps,
|
|
@@ -607,20 +556,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
607
556
|
// green light.
|
|
608
557
|
const outboundCameraSenderRef = useRef<RTCRtpSender | null>(null);
|
|
609
558
|
const outboundLocalStreamRef = useRef<MediaStream | null>(null);
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
// by a newer request and bail instead of re-attaching the camera.
|
|
613
|
-
const cameraRequestGenerationRef = useRef(0);
|
|
614
|
-
const cameraResolutionCapRef = useRef(cameraResolutionCap);
|
|
615
|
-
cameraResolutionCapRef.current = cameraResolutionCap;
|
|
616
|
-
// The aspect prop also rides a ref so the WS `onopen` reconnect
|
|
617
|
-
// path can replay the operator's last pick without depending on
|
|
618
|
-
// an in-flight render cycle. We mutate it inline (same render-
|
|
619
|
-
// time pattern as the cap ref) so a parent prop change is visible
|
|
620
|
-
// to closures captured during the next render; the useEffect
|
|
621
|
-
// below handles the actual "send to host" side-effect.
|
|
622
|
-
const cameraAspectRef = useRef<CameraAspect | undefined>(cameraAspect);
|
|
623
|
-
cameraAspectRef.current = cameraAspect;
|
|
559
|
+
const outboundCameraRequestRef = useRef<CameraRequest | undefined>(undefined);
|
|
560
|
+
const cameraOperations = useMemo(() => new CameraOperationCoordinator(), []);
|
|
624
561
|
// Mirror the demand-change callback into a ref so the WS message
|
|
625
562
|
// handler always sees the freshest customer callback even when the
|
|
626
563
|
// parent re-renders mid-session.
|
|
@@ -770,6 +707,16 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
770
707
|
onAxStatusChangeRef.current = onAxStatusChange;
|
|
771
708
|
const onTerminatedRef = useRef(onTerminated);
|
|
772
709
|
onTerminatedRef.current = onTerminated;
|
|
710
|
+
const onConnectionStateChangeRef = useRef(onConnectionStateChange);
|
|
711
|
+
onConnectionStateChangeRef.current = onConnectionStateChange;
|
|
712
|
+
const connectionStateRef = useRef<RemoteControlConnectionState | null>(null);
|
|
713
|
+
// Reports each transition once; callers get status text without tracking
|
|
714
|
+
// the retry machinery below.
|
|
715
|
+
const reportConnectionState = (next: RemoteControlConnectionState) => {
|
|
716
|
+
if (connectionStateRef.current === next) return;
|
|
717
|
+
connectionStateRef.current = next;
|
|
718
|
+
safeInvoke('onConnectionStateChange', onConnectionStateChangeRef.current, next);
|
|
719
|
+
};
|
|
773
720
|
|
|
774
721
|
const inspectActive = inspectMode === true || inspectMode === 'hover-only';
|
|
775
722
|
const inspectModeResolved: InspectMode = inspectMode === 'hover-only' ? 'hover-only' : 'select';
|
|
@@ -783,6 +730,17 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
783
730
|
|
|
784
731
|
const platform = useMemo(() => detectPlatform(url), [url]);
|
|
785
732
|
const config = deviceConfig[platform];
|
|
733
|
+
const platformAssets = assets?.[platform];
|
|
734
|
+
const frameImageSrc =
|
|
735
|
+
platform === 'android' && useAndroidTabletFrame ?
|
|
736
|
+
isLandscape ? platformAssets?.tabletFrameLandscape
|
|
737
|
+
: platformAssets?.tabletFrame
|
|
738
|
+
: isLandscape ? platformAssets?.frameLandscape
|
|
739
|
+
: platformAssets?.frame;
|
|
740
|
+
const loadingLogo = platformAssets?.loadingLogo;
|
|
741
|
+
// Without a frame image (the lite entry) the video lays out frameless
|
|
742
|
+
// whatever showFrame says.
|
|
743
|
+
const frameVisible = showFrame && !!frameImageSrc;
|
|
786
744
|
|
|
787
745
|
const updateStatus = (message: string) => {
|
|
788
746
|
// Use the wrapper for conditional logging
|
|
@@ -1877,6 +1835,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1877
1835
|
firstFrameShownRef.current = true;
|
|
1878
1836
|
stopRequestFrameLoop();
|
|
1879
1837
|
setVideoLoaded(true);
|
|
1838
|
+
reportConnectionState('connected');
|
|
1880
1839
|
};
|
|
1881
1840
|
|
|
1882
1841
|
// Stop every track on a MediaStream and release the device handle.
|
|
@@ -2055,32 +2014,30 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2055
2014
|
});
|
|
2056
2015
|
};
|
|
2057
2016
|
|
|
2058
|
-
//
|
|
2059
|
-
//
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
return { ...base, width: { ideal: 854 }, height: { ideal: 480 } };
|
|
2080
|
-
case 'auto':
|
|
2081
|
-
default:
|
|
2082
|
-
return base;
|
|
2017
|
+
// Publish a fresh settings snapshot after every successful capture
|
|
2018
|
+
// change. This is status metadata; host output sizing is frame-derived.
|
|
2019
|
+
const sendGrantedCameraResult = (ws: WebSocket, videoTrack: MediaStreamTrack) => {
|
|
2020
|
+
let result;
|
|
2021
|
+
try {
|
|
2022
|
+
result = createGrantedCameraResult(videoTrack);
|
|
2023
|
+
// eslint-disable-next-line no-console
|
|
2024
|
+
console.info(
|
|
2025
|
+
`[RemoteControl] camera capture: ${result.camera?.width}x${result.camera?.height}` +
|
|
2026
|
+
` @ ${result.camera?.frameRate ?? '?'}fps` +
|
|
2027
|
+
(result.camera?.label ? ` — ${result.camera.label}` : ''),
|
|
2028
|
+
);
|
|
2029
|
+
} catch (err) {
|
|
2030
|
+
debugWarn('getSettings() on outbound camera track failed:', err);
|
|
2031
|
+
result = {
|
|
2032
|
+
type: 'cameraResult' as const,
|
|
2033
|
+
granted: true,
|
|
2034
|
+
camera: {
|
|
2035
|
+
label: videoTrack.label || undefined,
|
|
2036
|
+
},
|
|
2037
|
+
};
|
|
2083
2038
|
}
|
|
2039
|
+
ws.send(JSON.stringify(result));
|
|
2040
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, true, result.camera);
|
|
2084
2041
|
};
|
|
2085
2042
|
|
|
2086
2043
|
// Convert the codec mime type (e.g. "video/H264" or "video/HEVC")
|
|
@@ -2192,6 +2149,210 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2192
2149
|
safeInvoke('onCameraStats', onCameraStatsRef.current, null);
|
|
2193
2150
|
};
|
|
2194
2151
|
|
|
2152
|
+
const handleCameraRequest = (
|
|
2153
|
+
request: CameraRequest,
|
|
2154
|
+
ws: WebSocket,
|
|
2155
|
+
sender: RTCRtpSender,
|
|
2156
|
+
isCurrentAttempt: () => boolean,
|
|
2157
|
+
): Promise<void> => {
|
|
2158
|
+
// `enqueue` increments its generation synchronously, so suspended
|
|
2159
|
+
// permission work becomes stale before the newer operation starts.
|
|
2160
|
+
return cameraOperations.enqueue(async ({ isCurrent }) => {
|
|
2161
|
+
const isCurrentCameraRequest = () =>
|
|
2162
|
+
isCurrent() &&
|
|
2163
|
+
isCurrentAttempt() &&
|
|
2164
|
+
wsRef.current === ws &&
|
|
2165
|
+
outboundCameraSenderRef.current === sender;
|
|
2166
|
+
if (!isCurrentCameraRequest()) return;
|
|
2167
|
+
|
|
2168
|
+
if (!request.active) {
|
|
2169
|
+
const stream = outboundLocalStreamRef.current;
|
|
2170
|
+
try {
|
|
2171
|
+
await sender.replaceTrack(null);
|
|
2172
|
+
} catch (err) {
|
|
2173
|
+
debugWarn('replaceTrack(null) on camera detach failed:', err);
|
|
2174
|
+
}
|
|
2175
|
+
if (outboundLocalStreamRef.current === stream) {
|
|
2176
|
+
outboundLocalStreamRef.current = null;
|
|
2177
|
+
outboundCameraRequestRef.current = undefined;
|
|
2178
|
+
if (stream) stopMediaStream(stream);
|
|
2179
|
+
}
|
|
2180
|
+
stopCameraStatsPoller();
|
|
2181
|
+
if (!isCurrentCameraRequest()) return;
|
|
2182
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, false);
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true);
|
|
2187
|
+
const currentStream = outboundLocalStreamRef.current;
|
|
2188
|
+
const currentTrack = currentStream?.getVideoTracks()[0];
|
|
2189
|
+
const currentRequest = outboundCameraRequestRef.current;
|
|
2190
|
+
|
|
2191
|
+
if (
|
|
2192
|
+
currentTrack?.readyState === 'live' &&
|
|
2193
|
+
currentRequest &&
|
|
2194
|
+
!shouldReacquireCamera(currentRequest, request)
|
|
2195
|
+
) {
|
|
2196
|
+
outboundCameraRequestRef.current = request;
|
|
2197
|
+
sendGrantedCameraResult(ws, currentTrack);
|
|
2198
|
+
startCameraStatsPoller();
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// A facing change always restarts browser capture. Releasing first
|
|
2203
|
+
// lets mobile browsers open the opposite camera; one-camera laptops
|
|
2204
|
+
// naturally return the same available input for the new ideal.
|
|
2205
|
+
if (currentStream) {
|
|
2206
|
+
try {
|
|
2207
|
+
await sender.replaceTrack(null);
|
|
2208
|
+
} catch (err) {
|
|
2209
|
+
debugWarn('replaceTrack(null) before camera switch failed:', err);
|
|
2210
|
+
}
|
|
2211
|
+
if (outboundLocalStreamRef.current === currentStream) {
|
|
2212
|
+
outboundLocalStreamRef.current = null;
|
|
2213
|
+
outboundCameraRequestRef.current = undefined;
|
|
2214
|
+
stopMediaStream(currentStream);
|
|
2215
|
+
}
|
|
2216
|
+
stopCameraStatsPoller();
|
|
2217
|
+
if (!isCurrentCameraRequest()) return;
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== 'function') {
|
|
2221
|
+
// eslint-disable-next-line no-console
|
|
2222
|
+
console.warn(
|
|
2223
|
+
'[RemoteControl] navigator.mediaDevices.getUserMedia unavailable. ' +
|
|
2224
|
+
'getUserMedia requires a secure context (https or http://localhost). ' +
|
|
2225
|
+
'Replying cameraResult granted=false so limulator falls back to black frames.',
|
|
2226
|
+
);
|
|
2227
|
+
if (!isCurrentCameraRequest()) return;
|
|
2228
|
+
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
2229
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
let stream: MediaStream | null = null;
|
|
2234
|
+
try {
|
|
2235
|
+
stream = await navigator.mediaDevices.getUserMedia({
|
|
2236
|
+
video: cameraCaptureConstraints(request),
|
|
2237
|
+
audio: false,
|
|
2238
|
+
});
|
|
2239
|
+
} catch (err) {
|
|
2240
|
+
// eslint-disable-next-line no-console
|
|
2241
|
+
console.warn('[RemoteControl] getUserMedia denied/failed:', err);
|
|
2242
|
+
}
|
|
2243
|
+
if (!isCurrentCameraRequest()) {
|
|
2244
|
+
if (stream) stopMediaStream(stream);
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
if (!stream) {
|
|
2248
|
+
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
2249
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
const videoTrack = stream.getVideoTracks()[0] ?? null;
|
|
2254
|
+
if (!videoTrack) {
|
|
2255
|
+
stopMediaStream(stream);
|
|
2256
|
+
if (!isCurrentCameraRequest()) return;
|
|
2257
|
+
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
2258
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
try {
|
|
2263
|
+
videoTrack.contentHint = 'motion';
|
|
2264
|
+
} catch {
|
|
2265
|
+
/* older browsers don't support contentHint; ignore */
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
try {
|
|
2269
|
+
await sender.replaceTrack(videoTrack);
|
|
2270
|
+
} catch (err) {
|
|
2271
|
+
debugWarn('replaceTrack(videoTrack) failed:', err);
|
|
2272
|
+
stopMediaStream(stream);
|
|
2273
|
+
if (!isCurrentCameraRequest()) return;
|
|
2274
|
+
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
2275
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
if (!isCurrentCameraRequest()) {
|
|
2279
|
+
try {
|
|
2280
|
+
await sender.replaceTrack(null);
|
|
2281
|
+
} catch (err) {
|
|
2282
|
+
debugWarn('replaceTrack(null) for stale camera attach failed:', err);
|
|
2283
|
+
}
|
|
2284
|
+
stopMediaStream(stream);
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
try {
|
|
2289
|
+
const params = sender.getParameters();
|
|
2290
|
+
if (!params.encodings || params.encodings.length === 0) {
|
|
2291
|
+
params.encodings = [{}];
|
|
2292
|
+
}
|
|
2293
|
+
params.encodings[0].maxBitrate = 8_000_000;
|
|
2294
|
+
params.encodings[0].maxFramerate = 30;
|
|
2295
|
+
params.degradationPreference = 'maintain-framerate';
|
|
2296
|
+
await sender.setParameters(params);
|
|
2297
|
+
} catch (err) {
|
|
2298
|
+
debugWarn('setParameters on outbound camera failed:', err);
|
|
2299
|
+
}
|
|
2300
|
+
if (!isCurrentCameraRequest()) {
|
|
2301
|
+
try {
|
|
2302
|
+
await sender.replaceTrack(null);
|
|
2303
|
+
} catch (err) {
|
|
2304
|
+
debugWarn('replaceTrack(null) for stale camera finalize failed:', err);
|
|
2305
|
+
}
|
|
2306
|
+
stopMediaStream(stream);
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
outboundLocalStreamRef.current = stream;
|
|
2311
|
+
outboundCameraRequestRef.current = request;
|
|
2312
|
+
|
|
2313
|
+
videoTrack.onended = () => {
|
|
2314
|
+
if (outboundLocalStreamRef.current !== stream) return;
|
|
2315
|
+
void cameraOperations
|
|
2316
|
+
.enqueueCurrent(async ({ isCurrent: isCurrentEndedCleanup }) => {
|
|
2317
|
+
if (
|
|
2318
|
+
!isCurrentEndedCleanup() ||
|
|
2319
|
+
outboundLocalStreamRef.current !== stream ||
|
|
2320
|
+
!isCurrentAttempt() ||
|
|
2321
|
+
wsRef.current !== ws ||
|
|
2322
|
+
outboundCameraSenderRef.current !== sender
|
|
2323
|
+
) {
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2326
|
+
try {
|
|
2327
|
+
await sender.replaceTrack(null);
|
|
2328
|
+
} catch (err) {
|
|
2329
|
+
debugWarn('replaceTrack(null) after camera ended failed:', err);
|
|
2330
|
+
}
|
|
2331
|
+
if (
|
|
2332
|
+
!isCurrentEndedCleanup() ||
|
|
2333
|
+
outboundLocalStreamRef.current !== stream ||
|
|
2334
|
+
!isCurrentAttempt() ||
|
|
2335
|
+
wsRef.current !== ws ||
|
|
2336
|
+
outboundCameraSenderRef.current !== sender
|
|
2337
|
+
) {
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
outboundLocalStreamRef.current = null;
|
|
2341
|
+
outboundCameraRequestRef.current = undefined;
|
|
2342
|
+
stopMediaStream(stream);
|
|
2343
|
+
stopCameraStatsPoller();
|
|
2344
|
+
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
2345
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
2346
|
+
})
|
|
2347
|
+
.catch((err) => debugWarn('camera ended cleanup failed:', err));
|
|
2348
|
+
};
|
|
2349
|
+
|
|
2350
|
+
if (!isCurrentCameraRequest()) return;
|
|
2351
|
+
sendGrantedCameraResult(ws, videoTrack);
|
|
2352
|
+
startCameraStatsPoller();
|
|
2353
|
+
});
|
|
2354
|
+
};
|
|
2355
|
+
|
|
2195
2356
|
const teardownConnection = () => {
|
|
2196
2357
|
clearConnectionSuccessTimeout();
|
|
2197
2358
|
clearIceDisconnectedGrace();
|
|
@@ -2203,8 +2364,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2203
2364
|
// Drop any active outbound camera before the PC dies so the
|
|
2204
2365
|
// browser doesn't leave the camera indicator lit between
|
|
2205
2366
|
// reconnects.
|
|
2367
|
+
cameraOperations.invalidate();
|
|
2206
2368
|
stopCameraStatsPoller();
|
|
2207
2369
|
stopOutboundLocalStream();
|
|
2370
|
+
outboundCameraRequestRef.current = undefined;
|
|
2208
2371
|
outboundCameraSenderRef.current = null;
|
|
2209
2372
|
// Same for the live microphone. Manual enablement survives reconnect;
|
|
2210
2373
|
// guest demand is replayed by the host after it assigns the new session.
|
|
@@ -2318,6 +2481,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2318
2481
|
clearIceDisconnectedGrace();
|
|
2319
2482
|
teardownConnection();
|
|
2320
2483
|
updateStatus('Instance terminated');
|
|
2484
|
+
reportConnectionState('terminated');
|
|
2321
2485
|
safeInvoke('onTerminated', onTerminatedRef.current);
|
|
2322
2486
|
};
|
|
2323
2487
|
|
|
@@ -2353,6 +2517,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2353
2517
|
if (!autoReconnectRef.current) {
|
|
2354
2518
|
updateStatus(`Connection failed after it was established: ${reason}`);
|
|
2355
2519
|
setRetryExhausted(true);
|
|
2520
|
+
reportConnectionState('failed');
|
|
2356
2521
|
teardownConnection();
|
|
2357
2522
|
return;
|
|
2358
2523
|
}
|
|
@@ -2368,11 +2533,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2368
2533
|
if (nextAttempt >= MAX_CONNECTION_ATTEMPTS) {
|
|
2369
2534
|
updateStatus(`Connection failed after ${MAX_CONNECTION_ATTEMPTS} attempts: ${reason}`);
|
|
2370
2535
|
setRetryExhausted(true);
|
|
2536
|
+
reportConnectionState('failed');
|
|
2371
2537
|
teardownConnection();
|
|
2372
2538
|
return;
|
|
2373
2539
|
}
|
|
2374
2540
|
|
|
2375
2541
|
updateStatus(`Retrying connection (${nextAttempt + 1}/${MAX_CONNECTION_ATTEMPTS})`);
|
|
2542
|
+
reportConnectionState('reconnecting');
|
|
2376
2543
|
teardownConnection();
|
|
2377
2544
|
retryTimeoutRef.current = window.setTimeout(() => {
|
|
2378
2545
|
retryTimeoutRef.current = undefined;
|
|
@@ -2392,6 +2559,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2392
2559
|
connectionAttemptRef.current = attemptNumber;
|
|
2393
2560
|
controlChannelOpenedRef.current = false;
|
|
2394
2561
|
setRetryExhausted(false);
|
|
2562
|
+
// A drop after an established session restarts the attempt budget at 0
|
|
2563
|
+
// while staying 'reconnecting'; only a fresh start reports 'connecting'.
|
|
2564
|
+
if (attemptNumber === 0 && connectionStateRef.current !== 'reconnecting')
|
|
2565
|
+
reportConnectionState('connecting');
|
|
2395
2566
|
clearScheduledRetry();
|
|
2396
2567
|
clearConnectionSuccessTimeout();
|
|
2397
2568
|
stopRequestFrameLoop();
|
|
@@ -2430,21 +2601,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2430
2601
|
if (!isCurrentAttempt() || wsRef.current !== ws) {
|
|
2431
2602
|
return;
|
|
2432
2603
|
}
|
|
2433
|
-
// Replay the saved camera aspect for fresh connections
|
|
2434
|
-
// (initial mount, autoreconnect, sim reboot). The host's
|
|
2435
|
-
// streamer starts at its boot default (16:9 / 1920×1080)
|
|
2436
|
-
// and a missing message would mean the operator's pick
|
|
2437
|
-
// silently reverts on reconnect. We always send something
|
|
2438
|
-
// when the prop is set, even if it matches the host
|
|
2439
|
-
// default — the host short-circuits no-op rebuilds.
|
|
2440
|
-
const initialAspect = cameraAspectRef.current;
|
|
2441
|
-
if (initialAspect) {
|
|
2442
|
-
try {
|
|
2443
|
-
ws.send(JSON.stringify({ type: 'cameraAspect', aspect: initialAspect }));
|
|
2444
|
-
} catch (err) {
|
|
2445
|
-
debugWarn('initial cameraAspect send failed:', err);
|
|
2446
|
-
}
|
|
2447
|
-
}
|
|
2448
2604
|
settle(resolve);
|
|
2449
2605
|
};
|
|
2450
2606
|
|
|
@@ -2489,24 +2645,43 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2489
2645
|
rtcConfiguration: RTCConfiguration;
|
|
2490
2646
|
cameraSupported: boolean;
|
|
2491
2647
|
}>((resolve, reject) => {
|
|
2492
|
-
const
|
|
2648
|
+
const settle = (callback: () => void) => {
|
|
2649
|
+
window.clearTimeout(timeoutId);
|
|
2650
|
+
ws.removeEventListener('message', messageHandler);
|
|
2651
|
+
callback();
|
|
2652
|
+
};
|
|
2653
|
+
const timeoutId = window.setTimeout(
|
|
2654
|
+
() => settle(() => reject(new Error('RTCConfiguration timeout'))),
|
|
2655
|
+
30000,
|
|
2656
|
+
);
|
|
2493
2657
|
|
|
2494
2658
|
const messageHandler = (event: MessageEvent) => {
|
|
2659
|
+
if (!isCurrentAttempt() || wsRef.current !== ws) {
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2495
2662
|
try {
|
|
2496
2663
|
const message = JSON.parse(event.data);
|
|
2497
|
-
if (
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2664
|
+
if (
|
|
2665
|
+
message.type === 'signalingError' &&
|
|
2666
|
+
message.requestType === 'requestRtcConfiguration' &&
|
|
2667
|
+
message.sessionId === sessionId
|
|
2668
|
+
) {
|
|
2669
|
+
const reason =
|
|
2670
|
+
typeof message.error === 'string' ?
|
|
2671
|
+
message.error
|
|
2672
|
+
: 'Unable to load streaming configuration';
|
|
2673
|
+
settle(() => reject(new Error(reason)));
|
|
2674
|
+
} else if (message.type === 'rtcConfiguration') {
|
|
2675
|
+
settle(() =>
|
|
2676
|
+
resolve({
|
|
2677
|
+
rtcConfiguration: message.rtcConfiguration,
|
|
2678
|
+
cameraSupported: message.cameraSupported === true,
|
|
2679
|
+
}),
|
|
2680
|
+
);
|
|
2504
2681
|
}
|
|
2505
2682
|
} catch (e) {
|
|
2506
|
-
window.clearTimeout(timeoutId);
|
|
2507
|
-
ws.removeEventListener('message', messageHandler);
|
|
2508
2683
|
console.error('Error handling RTC configuration:', e);
|
|
2509
|
-
reject(e);
|
|
2684
|
+
settle(() => reject(e));
|
|
2510
2685
|
}
|
|
2511
2686
|
};
|
|
2512
2687
|
|
|
@@ -2663,6 +2838,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2663
2838
|
platform,
|
|
2664
2839
|
baseIntervalMs: axPollIntervalMs,
|
|
2665
2840
|
maxBackoffMs: axMaxBackoffMs,
|
|
2841
|
+
androidElementTreeOptions,
|
|
2666
2842
|
send: (payload) => {
|
|
2667
2843
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return false;
|
|
2668
2844
|
try {
|
|
@@ -2864,6 +3040,18 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2864
3040
|
}
|
|
2865
3041
|
updateStatus('Received: ' + message.type);
|
|
2866
3042
|
switch (message.type) {
|
|
3043
|
+
case 'signalingError': {
|
|
3044
|
+
if (message.requestType !== 'offer' || message.sessionId !== sessionId) {
|
|
3045
|
+
break;
|
|
3046
|
+
}
|
|
3047
|
+
const reason =
|
|
3048
|
+
typeof message.error === 'string' ?
|
|
3049
|
+
message.error
|
|
3050
|
+
: 'Unable to establish the streaming connection';
|
|
3051
|
+
updateStatus('Error: ' + reason);
|
|
3052
|
+
scheduleRetry(reason, generation);
|
|
3053
|
+
break;
|
|
3054
|
+
}
|
|
2867
3055
|
case 'answer':
|
|
2868
3056
|
if (!peerConnectionRef.current || peerConnectionRef.current !== peerConnection) {
|
|
2869
3057
|
updateStatus('No peer connection, skipping answer');
|
|
@@ -2952,235 +3140,18 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2952
3140
|
// outbound-camera transceiver; without it there is nothing to
|
|
2953
3141
|
// feed the device, so ignore camera requests entirely — no
|
|
2954
3142
|
// getUserMedia prompt and no onCameraDemandChange callbacks.
|
|
2955
|
-
if (!outboundCameraSenderRef.current) {
|
|
2956
|
-
break;
|
|
2957
|
-
}
|
|
2958
|
-
const active = message.active === true;
|
|
2959
|
-
// Bump up front so any earlier in-flight handler bails.
|
|
2960
|
-
const cameraGeneration = ++cameraRequestGenerationRef.current;
|
|
2961
|
-
const isCurrentCameraRequest = () =>
|
|
2962
|
-
isCurrentAttempt() &&
|
|
2963
|
-
wsRef.current === ws &&
|
|
2964
|
-
cameraGeneration === cameraRequestGenerationRef.current;
|
|
2965
|
-
// Log unconditionally — this is one of the few places we
|
|
2966
|
-
// hand control to the browser's permission UI, and a
|
|
2967
|
-
// silent failure here looks identical to "limulator
|
|
2968
|
-
// never asked" from the user's point of view.
|
|
2969
|
-
// eslint-disable-next-line no-console
|
|
2970
|
-
console.info('[RemoteControl] cameraRequest received, active=', active);
|
|
2971
|
-
if (!active) {
|
|
2972
|
-
// Sim no longer wants frames. Drop our local track and
|
|
2973
|
-
// shut the browser's camera green light off.
|
|
2974
|
-
const sender = outboundCameraSenderRef.current;
|
|
2975
|
-
if (sender) {
|
|
2976
|
-
try {
|
|
2977
|
-
await sender.replaceTrack(null);
|
|
2978
|
-
} catch (err) {
|
|
2979
|
-
debugWarn('replaceTrack(null) on camera detach failed:', err);
|
|
2980
|
-
}
|
|
2981
|
-
}
|
|
2982
|
-
stopCameraStatsPoller();
|
|
2983
|
-
stopOutboundLocalStream();
|
|
2984
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, false);
|
|
2985
|
-
break;
|
|
2986
|
-
}
|
|
2987
|
-
// Sim is asking for camera. Ask the browser; the user's
|
|
2988
|
-
// prompt response is reported back to the host via a
|
|
2989
|
-
// `cameraResult` message so it can swap to a
|
|
2990
|
-
// black-frame fallback on denial.
|
|
2991
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true);
|
|
2992
|
-
if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== 'function') {
|
|
2993
|
-
// Likely an insecure context (http on a non-localhost
|
|
2994
|
-
// origin) — Chrome strips `mediaDevices` off
|
|
2995
|
-
// `navigator` in that case and the only signal is
|
|
2996
|
-
// this undefined check.
|
|
2997
|
-
// eslint-disable-next-line no-console
|
|
2998
|
-
console.warn(
|
|
2999
|
-
'[RemoteControl] navigator.mediaDevices.getUserMedia unavailable. ' +
|
|
3000
|
-
'getUserMedia requires a secure context (https or http://localhost). ' +
|
|
3001
|
-
'Replying cameraResult granted=false so limulator falls back to black frames.',
|
|
3002
|
-
);
|
|
3003
|
-
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
3004
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
3005
|
-
break;
|
|
3006
|
-
}
|
|
3007
|
-
let stream: MediaStream | null = null;
|
|
3008
|
-
try {
|
|
3009
|
-
// Capture at the webcam's *native* resolution by
|
|
3010
|
-
// default (no width/height constraints). When the
|
|
3011
|
-
// host app has picked an explicit cap via
|
|
3012
|
-
// `cameraResolutionCap`, we honour it here. Frame
|
|
3013
|
-
// rate is always capped to 30 to match the
|
|
3014
|
-
// simulator pool's pacing.
|
|
3015
|
-
stream = await navigator.mediaDevices.getUserMedia({
|
|
3016
|
-
video: cameraCapToConstraints(cameraResolutionCapRef.current),
|
|
3017
|
-
audio: false,
|
|
3018
|
-
});
|
|
3019
|
-
} catch (err) {
|
|
3020
|
-
// Surface unconditionally — the user is the only one
|
|
3021
|
-
// who can fix this (permission denied, no device,
|
|
3022
|
-
// dismissed prompt, etc.). `debugWarn` alone would
|
|
3023
|
-
// hide it in normal builds.
|
|
3024
|
-
// eslint-disable-next-line no-console
|
|
3025
|
-
console.warn('[RemoteControl] getUserMedia denied/failed:', err);
|
|
3026
|
-
}
|
|
3027
|
-
if (!isCurrentCameraRequest()) {
|
|
3028
|
-
// Superseded during the prompt; drop the stream we got.
|
|
3029
|
-
if (stream) stopMediaStream(stream);
|
|
3030
|
-
return;
|
|
3031
|
-
}
|
|
3032
|
-
if (!stream) {
|
|
3033
|
-
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
3034
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
3035
|
-
break;
|
|
3036
|
-
}
|
|
3037
|
-
// Replace any previous local stream (e.g. an earlier
|
|
3038
|
-
// cameraRequest that resolved with a different device)
|
|
3039
|
-
// before we install the new tracks.
|
|
3040
|
-
stopOutboundLocalStream();
|
|
3041
|
-
outboundLocalStreamRef.current = stream;
|
|
3042
3143
|
const sender = outboundCameraSenderRef.current;
|
|
3043
|
-
|
|
3044
|
-
if (!sender || !videoTrack) {
|
|
3045
|
-
if (stream) stopMediaStream(stream);
|
|
3046
|
-
outboundLocalStreamRef.current = null;
|
|
3047
|
-
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
3048
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
3144
|
+
if (!sender) {
|
|
3049
3145
|
break;
|
|
3050
3146
|
}
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
debugWarn('replaceTrack(videoTrack) failed:', err);
|
|
3055
|
-
stopMediaStream(stream);
|
|
3056
|
-
outboundLocalStreamRef.current = null;
|
|
3057
|
-
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
3058
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
3147
|
+
const request = parseCameraRequest(message);
|
|
3148
|
+
if (!request) {
|
|
3149
|
+
debugWarn('Received invalid cameraRequest:', message);
|
|
3059
3150
|
break;
|
|
3060
3151
|
}
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
await sender.replaceTrack(null);
|
|
3065
|
-
} catch (err) {
|
|
3066
|
-
debugWarn('replaceTrack(null) on stale camera attach failed:', err);
|
|
3067
|
-
}
|
|
3068
|
-
stopMediaStream(stream);
|
|
3069
|
-
if (outboundLocalStreamRef.current === stream) {
|
|
3070
|
-
outboundLocalStreamRef.current = null;
|
|
3071
|
-
}
|
|
3072
|
-
return;
|
|
3073
|
-
}
|
|
3074
|
-
// Tell the encoder this is real motion content (a
|
|
3075
|
-
// physical camera), not text/slides. With `'motion'`
|
|
3076
|
-
// VideoToolbox / libvpx pick latency-friendly tuning
|
|
3077
|
-
// (no extra B-frames, shorter GoP smoothing). Set
|
|
3078
|
-
// before the first frame so the encoder init reads it.
|
|
3079
|
-
try {
|
|
3080
|
-
videoTrack.contentHint = 'motion';
|
|
3081
|
-
} catch {
|
|
3082
|
-
/* older browsers don't support contentHint; ignore */
|
|
3083
|
-
}
|
|
3084
|
-
// Bound the outbound bitrate generously and let
|
|
3085
|
-
// WebRTC's congestion control (BWE) find the floor.
|
|
3086
|
-
// 8 Mbps is comfortable for native 1080p30 webcam
|
|
3087
|
-
// content over LAN — the encoder will use far less when
|
|
3088
|
-
// the scene is static, and BWE will throttle if a
|
|
3089
|
-
// hop is constrained. Pair with
|
|
3090
|
-
// `maintain-framerate` so the quality scaler steps
|
|
3091
|
-
// down resolution before dropping frames, matching how
|
|
3092
|
-
// Meet/Zoom degrade.
|
|
3093
|
-
try {
|
|
3094
|
-
const params = sender.getParameters();
|
|
3095
|
-
if (!params.encodings || params.encodings.length === 0) {
|
|
3096
|
-
params.encodings = [{}];
|
|
3097
|
-
}
|
|
3098
|
-
params.encodings[0].maxBitrate = 8_000_000;
|
|
3099
|
-
params.encodings[0].maxFramerate = 30;
|
|
3100
|
-
params.degradationPreference = 'maintain-framerate';
|
|
3101
|
-
await sender.setParameters(params);
|
|
3102
|
-
} catch (err) {
|
|
3103
|
-
debugWarn('setParameters on outbound camera failed:', err);
|
|
3104
|
-
}
|
|
3105
|
-
// Surface the actual captured geometry to the host so
|
|
3106
|
-
// it can size its IOSurface pool / picture-format
|
|
3107
|
-
// metadata to match. `getSettings()` returns what the
|
|
3108
|
-
// browser actually picked — which may differ from any
|
|
3109
|
-
// hints we sent in the constraints — including the
|
|
3110
|
-
// selected `deviceId` / `label` (useful when a user has
|
|
3111
|
-
// multiple cameras and we eventually expose a picker).
|
|
3112
|
-
let cameraMetadata: {
|
|
3113
|
-
width?: number;
|
|
3114
|
-
height?: number;
|
|
3115
|
-
frameRate?: number;
|
|
3116
|
-
deviceId?: string;
|
|
3117
|
-
label?: string;
|
|
3118
|
-
facingMode?: string;
|
|
3119
|
-
} = {};
|
|
3120
|
-
try {
|
|
3121
|
-
const settings = videoTrack.getSettings();
|
|
3122
|
-
cameraMetadata = {
|
|
3123
|
-
width: settings.width,
|
|
3124
|
-
height: settings.height,
|
|
3125
|
-
frameRate: settings.frameRate,
|
|
3126
|
-
deviceId: settings.deviceId,
|
|
3127
|
-
label: videoTrack.label || undefined,
|
|
3128
|
-
facingMode: settings.facingMode,
|
|
3129
|
-
};
|
|
3130
|
-
// eslint-disable-next-line no-console
|
|
3131
|
-
console.info(
|
|
3132
|
-
`[RemoteControl] camera capture: ${cameraMetadata.width}x${cameraMetadata.height}` +
|
|
3133
|
-
` @ ${cameraMetadata.frameRate ?? '?'}fps` +
|
|
3134
|
-
(cameraMetadata.label ? ` — ${cameraMetadata.label}` : ''),
|
|
3135
|
-
);
|
|
3136
|
-
} catch (err) {
|
|
3137
|
-
debugWarn('getSettings() on outbound camera track failed:', err);
|
|
3138
|
-
}
|
|
3139
|
-
// If the browser revokes the track later (extension,
|
|
3140
|
-
// user clicks Stop in the camera tab UI, etc.), notify
|
|
3141
|
-
// the host so it can switch to black frames.
|
|
3142
|
-
videoTrack.onended = () => {
|
|
3143
|
-
if (outboundLocalStreamRef.current !== stream) return;
|
|
3144
|
-
stopCameraStatsPoller();
|
|
3145
|
-
stopOutboundLocalStream();
|
|
3146
|
-
if (wsRef.current === ws && ws.readyState === WebSocket.OPEN) {
|
|
3147
|
-
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
3148
|
-
}
|
|
3149
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
3150
|
-
};
|
|
3151
|
-
if (!isCurrentCameraRequest()) {
|
|
3152
|
-
// Superseded during setParameters; detach and bail.
|
|
3153
|
-
videoTrack.onended = null;
|
|
3154
|
-
try {
|
|
3155
|
-
await sender.replaceTrack(null);
|
|
3156
|
-
} catch (err) {
|
|
3157
|
-
debugWarn('replaceTrack(null) on stale camera finalize failed:', err);
|
|
3158
|
-
}
|
|
3159
|
-
stopMediaStream(stream);
|
|
3160
|
-
if (outboundLocalStreamRef.current === stream) {
|
|
3161
|
-
outboundLocalStreamRef.current = null;
|
|
3162
|
-
}
|
|
3163
|
-
return;
|
|
3164
|
-
}
|
|
3165
|
-
ws.send(
|
|
3166
|
-
JSON.stringify({
|
|
3167
|
-
type: 'cameraResult',
|
|
3168
|
-
granted: true,
|
|
3169
|
-
// Forward what the browser actually captured so the
|
|
3170
|
-
// host can size its IOSurface pool, log the device,
|
|
3171
|
-
// and (eventually) surface a status pill / picker.
|
|
3172
|
-
camera: cameraMetadata,
|
|
3173
|
-
}),
|
|
3174
|
-
);
|
|
3175
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, true, cameraMetadata);
|
|
3176
|
-
// Kick off the per-second outbound stats sampler. We do
|
|
3177
|
-
// this *after* `setParameters` so the first sample
|
|
3178
|
-
// already sees the encoder under its final bitrate /
|
|
3179
|
-
// degradation policy, and *after* the host-side
|
|
3180
|
-
// attachInboundTrack will have wired up (the
|
|
3181
|
-
// cameraResult ACK is what triggers it on the host),
|
|
3182
|
-
// so framesEncoded starts climbing immediately.
|
|
3183
|
-
startCameraStatsPoller();
|
|
3152
|
+
// eslint-disable-next-line no-console
|
|
3153
|
+
console.info('[RemoteControl] cameraRequest received:', request.active, request.facingMode);
|
|
3154
|
+
await handleCameraRequest(request, ws, sender, isCurrentAttempt);
|
|
3184
3155
|
break;
|
|
3185
3156
|
}
|
|
3186
3157
|
case 'microphoneRequest': {
|
|
@@ -3313,41 +3284,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3313
3284
|
start();
|
|
3314
3285
|
};
|
|
3315
3286
|
|
|
3316
|
-
// Re-apply the resolution cap on the currently-sending track
|
|
3317
|
-
// whenever the host app changes its preference. Skips when no
|
|
3318
|
-
// camera is active — the next `getUserMedia` will pick up the
|
|
3319
|
-
// new value via `cameraCapToConstraints` automatically.
|
|
3320
|
-
useEffect(() => {
|
|
3321
|
-
const stream = outboundLocalStreamRef.current;
|
|
3322
|
-
if (!stream) return;
|
|
3323
|
-
const track = stream.getVideoTracks()[0];
|
|
3324
|
-
if (!track) return;
|
|
3325
|
-
const constraints = cameraCapToConstraints(cameraResolutionCap);
|
|
3326
|
-
track.applyConstraints(constraints).catch((err) => {
|
|
3327
|
-
debugWarn('applyConstraints for new camera cap failed:', err);
|
|
3328
|
-
});
|
|
3329
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3330
|
-
}, [cameraResolutionCap]);
|
|
3331
|
-
|
|
3332
|
-
// Push the aspect preference to the host whenever it changes.
|
|
3333
|
-
// The host rebuilds its IOSurface pool and bumps `pool_generation`
|
|
3334
|
-
// on its end; the dylib re-handshakes on next sem_wait. No
|
|
3335
|
-
// peer-connection renegotiation is needed — the aspect change is
|
|
3336
|
-
// purely about the pixel buffer dimensions iOS apps observe, not
|
|
3337
|
-
// about WebRTC track layout. The `cameraAspectRef` is mirrored
|
|
3338
|
-
// higher up so the WS `onopen` reconnect path can replay the
|
|
3339
|
-
// latest value on fresh connections.
|
|
3340
|
-
useEffect(() => {
|
|
3341
|
-
if (!cameraAspect) return;
|
|
3342
|
-
const ws = wsRef.current;
|
|
3343
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
3344
|
-
try {
|
|
3345
|
-
ws.send(JSON.stringify({ type: 'cameraAspect', aspect: cameraAspect }));
|
|
3346
|
-
} catch (err) {
|
|
3347
|
-
debugWarn('cameraAspect send failed:', err);
|
|
3348
|
-
}
|
|
3349
|
-
}, [cameraAspect]);
|
|
3350
|
-
|
|
3351
3287
|
// Drive the manual override without interrupting guest-driven demand.
|
|
3352
3288
|
useEffect(() => {
|
|
3353
3289
|
if (isMicrophoneWanted()) {
|
|
@@ -3426,7 +3362,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3426
3362
|
const updateVideoPosition = () => {
|
|
3427
3363
|
// If no frame, just refresh overlay geometry; no inset/letterbox math
|
|
3428
3364
|
// is needed since the video element is its own size.
|
|
3429
|
-
if (!
|
|
3365
|
+
if (!frameVisible || !frame) {
|
|
3430
3366
|
setVideoStyle({});
|
|
3431
3367
|
recomputeOverlayGeometry();
|
|
3432
3368
|
return;
|
|
@@ -3499,7 +3435,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3499
3435
|
video.removeEventListener('resize', bumpOnResize);
|
|
3500
3436
|
if (frame) frame.removeEventListener('load', updateVideoPosition);
|
|
3501
3437
|
};
|
|
3502
|
-
}, [config,
|
|
3438
|
+
}, [config, frameVisible]);
|
|
3503
3439
|
|
|
3504
3440
|
// Start/stop the AX poller and reset inspect state when inspect mode
|
|
3505
3441
|
// toggles. Connection state is independent: the fetcher gets created on
|
|
@@ -3743,12 +3679,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3743
3679
|
|
|
3744
3680
|
// Show indicators when Alt is held and we have a valid hover point (null when outside)
|
|
3745
3681
|
const showAltIndicators = isAltHeld && hoverPoint !== null;
|
|
3746
|
-
const frameImageSrc =
|
|
3747
|
-
platform === 'android' && useAndroidTabletFrame ?
|
|
3748
|
-
isLandscape ? pixelTabletFrameImageLandscape
|
|
3749
|
-
: pixelTabletFrameImage
|
|
3750
|
-
: isLandscape ? config.frame.imageLandscape
|
|
3751
|
-
: config.frame.image;
|
|
3752
3682
|
|
|
3753
3683
|
return (
|
|
3754
3684
|
<div
|
|
@@ -3784,7 +3714,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3784
3714
|
/>
|
|
3785
3715
|
</>
|
|
3786
3716
|
)}
|
|
3787
|
-
{
|
|
3717
|
+
{frameVisible && (
|
|
3788
3718
|
<img
|
|
3789
3719
|
ref={frameRef}
|
|
3790
3720
|
src={frameImageSrc}
|
|
@@ -3795,12 +3725,16 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3795
3725
|
)}
|
|
3796
3726
|
<video
|
|
3797
3727
|
ref={videoRef}
|
|
3798
|
-
className={clsx(
|
|
3728
|
+
className={clsx(
|
|
3729
|
+
'rc-video',
|
|
3730
|
+
!frameVisible && 'rc-video-frameless',
|
|
3731
|
+
!videoLoaded && 'rc-video-loading',
|
|
3732
|
+
)}
|
|
3799
3733
|
style={{
|
|
3800
3734
|
...videoStyle,
|
|
3801
|
-
...(
|
|
3735
|
+
...(loadingLogo ?
|
|
3802
3736
|
{
|
|
3803
|
-
backgroundImage: `url("${
|
|
3737
|
+
backgroundImage: `url("${loadingLogo}")`,
|
|
3804
3738
|
backgroundRepeat: 'no-repeat',
|
|
3805
3739
|
backgroundPosition: 'center',
|
|
3806
3740
|
backgroundSize: config.loadingLogoSize,
|