@limrun/ui 0.14.3 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/dist/components/remote-control.d.ts +16 -76
- package/dist/core/ax-fetcher.d.ts +5 -0
- package/dist/core/camera.d.ts +58 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1453 -1402
- package/package.json +2 -3
- package/src/components/remote-control.tsx +299 -416
- 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/index.ts +8 -2
|
@@ -18,17 +18,28 @@ import {
|
|
|
18
18
|
createSetClipboardMessage,
|
|
19
19
|
createTwoFingerTouchControlMessage,
|
|
20
20
|
} from '../core/webrtc-messages';
|
|
21
|
-
import { AxFetcher, AxStatus } from '../core/ax-fetcher';
|
|
21
|
+
import { AxFetcher, AxStatus, type AndroidElementTreeOptions } from '../core/ax-fetcher';
|
|
22
22
|
import { AxElement, AxSnapshot, axElementAtPoint, axSnapshotsEqual } from '../core/ax-tree';
|
|
23
|
+
import {
|
|
24
|
+
CameraOperationCoordinator,
|
|
25
|
+
cameraCaptureConstraints,
|
|
26
|
+
createGrantedCameraResult,
|
|
27
|
+
parseCameraRequest,
|
|
28
|
+
shouldReacquireCamera,
|
|
29
|
+
type CameraCaptureInfo,
|
|
30
|
+
type CameraRequest,
|
|
31
|
+
} from '../core/camera';
|
|
23
32
|
import { InspectOverlay, InspectOverlayGeometry, InspectMode } from './inspect-overlay';
|
|
24
33
|
|
|
34
|
+
export type { CameraCaptureInfo, CameraFacingMode, CameraRequest, CameraResult } from '../core/camera';
|
|
35
|
+
|
|
25
36
|
declare global {
|
|
26
37
|
interface Window {
|
|
27
38
|
debugRemoteControl?: boolean;
|
|
28
39
|
}
|
|
29
40
|
}
|
|
30
41
|
|
|
31
|
-
interface RemoteControlProps {
|
|
42
|
+
export interface RemoteControlProps {
|
|
32
43
|
// url is the URL of the instance to connect to.
|
|
33
44
|
url: string;
|
|
34
45
|
|
|
@@ -154,6 +165,8 @@ interface RemoteControlProps {
|
|
|
154
165
|
*/
|
|
155
166
|
axMaxBackoffMs?: number;
|
|
156
167
|
|
|
168
|
+
androidElementTreeOptions?: AndroidElementTreeOptions;
|
|
169
|
+
|
|
157
170
|
/**
|
|
158
171
|
* Fires whenever the iOS simulator's camera demand state changes —
|
|
159
172
|
* i.e. an app inside the sim called
|
|
@@ -163,12 +176,10 @@ interface RemoteControlProps {
|
|
|
163
176
|
* UI can render a status indicator ("simulator is using your
|
|
164
177
|
* camera", etc.).
|
|
165
178
|
*
|
|
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).
|
|
179
|
+
* `active` reflects whether the sim is currently asking for frames.
|
|
180
|
+
* `granted` is set on capture acknowledgements: `true` after a new
|
|
181
|
+
* track, reused track, or constraint update; `false` when capture
|
|
182
|
+
* fails. The host switches to black frames after a failure.
|
|
172
183
|
*
|
|
173
184
|
* `camera` (optional, only meaningful when `granted === true`)
|
|
174
185
|
* carries what `MediaStreamTrack.getSettings()` reported for the
|
|
@@ -176,8 +187,9 @@ interface RemoteControlProps {
|
|
|
176
187
|
* mode. Use it to render a richer status indicator (e.g.
|
|
177
188
|
* "Camera · 1920×1080 · 30 fps · FaceTime HD").
|
|
178
189
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
190
|
+
* iOS instances always fire this callback. Android instances fire it
|
|
191
|
+
* when the server supports camera injection (Android 15+); older
|
|
192
|
+
* Android instances have no camera-injector path and stay silent.
|
|
181
193
|
*/
|
|
182
194
|
onCameraDemandChange?: (active: boolean, granted?: boolean, camera?: CameraCaptureInfo) => void;
|
|
183
195
|
|
|
@@ -200,49 +212,15 @@ interface RemoteControlProps {
|
|
|
200
212
|
onCameraStats?: (stats: CameraStreamStats | null) => void;
|
|
201
213
|
|
|
202
214
|
/**
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* - `'auto'` (default): no extra constraint. The browser captures
|
|
206
|
-
* at its webcam's native max; WebRTC's quality scaler may step
|
|
207
|
-
* down resolution on the encoder side under bandwidth pressure
|
|
208
|
-
* while still feeding the simulator at the pool's native size.
|
|
209
|
-
* - `'1080p'` / `'720p'` / `'480p'`: hard cap applied via
|
|
210
|
-
* `getUserMedia` constraints (for new captures) and
|
|
211
|
-
* `track.applyConstraints` (for the currently-active track),
|
|
212
|
-
* matching the way Meet/Zoom expose a "Send resolution" picker.
|
|
213
|
-
*
|
|
214
|
-
* Bumping or lowering the cap mid-stream is supported; the
|
|
215
|
-
* change takes effect within a frame or two as the webcam
|
|
216
|
-
* re-negotiates.
|
|
217
|
-
*/
|
|
218
|
-
cameraResolutionCap?: CameraResolutionCap;
|
|
219
|
-
/**
|
|
220
|
-
* Aspect ratio the simulator's virtual camera should report to apps.
|
|
221
|
-
* Picking a value here triggers a `cameraAspect` WS message to the
|
|
222
|
-
* host, which rebuilds its IOSurface ring at the matching dimensions
|
|
223
|
-
* (16:9 → 1920×1080, 4:3 → 1440×1080, 1:1 → 1080×1080, 9:16 →
|
|
224
|
-
* 1080×1920) and signals the in-sim dylib to re-handshake. iOS apps
|
|
225
|
-
* see CMSampleBuffers at the new dimensions within a frame or two.
|
|
226
|
-
*
|
|
227
|
-
* The browser still captures whatever the webcam offers; the host
|
|
228
|
-
* aspect-fills (cover, center-crop) into the new pool. Switching
|
|
229
|
-
* aspect at runtime is intentionally cheap so users can A/B preview
|
|
230
|
-
* styles without restarting the simulator.
|
|
231
|
-
*
|
|
232
|
-
* `undefined` leaves the pool untouched (the host's boot default —
|
|
233
|
-
* 16:9 / 1920×1080 — applies).
|
|
234
|
-
*/
|
|
235
|
-
cameraAspect?: CameraAspect;
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* iOS only: when true, capture the user's microphone via
|
|
215
|
+
* When true, capture the user's microphone via
|
|
239
216
|
* `getUserMedia({ audio: true })` and stream it live into the
|
|
240
|
-
*
|
|
217
|
+
* device's mock microphone. This is a manual override: apps
|
|
241
218
|
* that actively consume microphone input also trigger capture
|
|
242
219
|
* automatically, like camera demand. The component handles the
|
|
243
220
|
* browser permission prompt, WebRTC track attachment, and
|
|
244
221
|
* `microphoneStart`/`microphoneStop` signaling. Progress and failures
|
|
245
|
-
* are reported via `onMicrophoneStateChange`. Ignored on Android
|
|
222
|
+
* are reported via `onMicrophoneStateChange`. Ignored on Android
|
|
223
|
+
* servers without camera/mic injection support (Android 14 and older).
|
|
246
224
|
*/
|
|
247
225
|
microphoneEnabled?: boolean;
|
|
248
226
|
|
|
@@ -271,37 +249,6 @@ export interface MicrophoneState {
|
|
|
271
249
|
label?: string;
|
|
272
250
|
}
|
|
273
251
|
|
|
274
|
-
/**
|
|
275
|
-
* Resolution caps a host app can request on the outbound camera.
|
|
276
|
-
* `'auto'` is "let the browser decide" (no constraints beyond the
|
|
277
|
-
* 30 fps ceiling); the other options clamp width/height to the
|
|
278
|
-
* named target. Aspect ratio is preserved.
|
|
279
|
-
*/
|
|
280
|
-
export type CameraResolutionCap = 'auto' | '1080p' | '720p' | '480p';
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Aspect ratios exposed to the operator for the simulated camera.
|
|
284
|
-
* The host maps each label to concrete IOSurface dimensions; values
|
|
285
|
-
* the host doesn't recognise are silently ignored.
|
|
286
|
-
*/
|
|
287
|
-
export type CameraAspect = '16:9' | '4:3' | '1:1' | '9:16';
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Snapshot of the browser's webcam capture, mirrored from
|
|
291
|
-
* `MediaStreamTrack.getSettings()`. Forwarded to the host alongside
|
|
292
|
-
* `cameraResult` and exposed to the host app via
|
|
293
|
-
* `onCameraDemandChange` so it can render a status indicator without
|
|
294
|
-
* having to call `getStats()` itself.
|
|
295
|
-
*/
|
|
296
|
-
export interface CameraCaptureInfo {
|
|
297
|
-
width?: number;
|
|
298
|
-
height?: number;
|
|
299
|
-
frameRate?: number;
|
|
300
|
-
deviceId?: string;
|
|
301
|
-
label?: string;
|
|
302
|
-
facingMode?: string;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
252
|
/**
|
|
306
253
|
* Live outbound-camera quality snapshot. Derived from
|
|
307
254
|
* `RTCPeerConnection.getStats()` and rate-derived deltas, sampled
|
|
@@ -549,10 +496,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
549
496
|
onAxStatusChange,
|
|
550
497
|
axPollIntervalMs,
|
|
551
498
|
axMaxBackoffMs,
|
|
499
|
+
androidElementTreeOptions,
|
|
552
500
|
onCameraDemandChange,
|
|
553
501
|
onCameraStats,
|
|
554
|
-
cameraResolutionCap = 'auto',
|
|
555
|
-
cameraAspect,
|
|
556
502
|
microphoneEnabled = false,
|
|
557
503
|
onMicrophoneStateChange,
|
|
558
504
|
}: RemoteControlProps,
|
|
@@ -605,20 +551,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
605
551
|
// green light.
|
|
606
552
|
const outboundCameraSenderRef = useRef<RTCRtpSender | null>(null);
|
|
607
553
|
const outboundLocalStreamRef = useRef<MediaStream | null>(null);
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
// by a newer request and bail instead of re-attaching the camera.
|
|
611
|
-
const cameraRequestGenerationRef = useRef(0);
|
|
612
|
-
const cameraResolutionCapRef = useRef(cameraResolutionCap);
|
|
613
|
-
cameraResolutionCapRef.current = cameraResolutionCap;
|
|
614
|
-
// The aspect prop also rides a ref so the WS `onopen` reconnect
|
|
615
|
-
// path can replay the operator's last pick without depending on
|
|
616
|
-
// an in-flight render cycle. We mutate it inline (same render-
|
|
617
|
-
// time pattern as the cap ref) so a parent prop change is visible
|
|
618
|
-
// to closures captured during the next render; the useEffect
|
|
619
|
-
// below handles the actual "send to host" side-effect.
|
|
620
|
-
const cameraAspectRef = useRef<CameraAspect | undefined>(cameraAspect);
|
|
621
|
-
cameraAspectRef.current = cameraAspect;
|
|
554
|
+
const outboundCameraRequestRef = useRef<CameraRequest | undefined>(undefined);
|
|
555
|
+
const cameraOperations = useMemo(() => new CameraOperationCoordinator(), []);
|
|
622
556
|
// Mirror the demand-change callback into a ref so the WS message
|
|
623
557
|
// handler always sees the freshest customer callback even when the
|
|
624
558
|
// parent re-renders mid-session.
|
|
@@ -1931,7 +1865,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1931
1865
|
// handler firing on both `connected` and `completed`) must not
|
|
1932
1866
|
// cancel an attach already in flight; only detach/teardown cancel.
|
|
1933
1867
|
const attachMicrophone = (): Promise<void> => {
|
|
1934
|
-
|
|
1868
|
+
// No platform gate: attachMicrophoneOp bails when the session never
|
|
1869
|
+
// allocated a sendonly mic transceiver (non-capable servers).
|
|
1935
1870
|
return runMicOp(() => attachMicrophoneOp());
|
|
1936
1871
|
};
|
|
1937
1872
|
|
|
@@ -2052,32 +1987,30 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2052
1987
|
});
|
|
2053
1988
|
};
|
|
2054
1989
|
|
|
2055
|
-
//
|
|
2056
|
-
//
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
return { ...base, width: { ideal: 854 }, height: { ideal: 480 } };
|
|
2077
|
-
case 'auto':
|
|
2078
|
-
default:
|
|
2079
|
-
return base;
|
|
1990
|
+
// Publish a fresh settings snapshot after every successful capture
|
|
1991
|
+
// change. This is status metadata; host output sizing is frame-derived.
|
|
1992
|
+
const sendGrantedCameraResult = (ws: WebSocket, videoTrack: MediaStreamTrack) => {
|
|
1993
|
+
let result;
|
|
1994
|
+
try {
|
|
1995
|
+
result = createGrantedCameraResult(videoTrack);
|
|
1996
|
+
// eslint-disable-next-line no-console
|
|
1997
|
+
console.info(
|
|
1998
|
+
`[RemoteControl] camera capture: ${result.camera?.width}x${result.camera?.height}` +
|
|
1999
|
+
` @ ${result.camera?.frameRate ?? '?'}fps` +
|
|
2000
|
+
(result.camera?.label ? ` — ${result.camera.label}` : ''),
|
|
2001
|
+
);
|
|
2002
|
+
} catch (err) {
|
|
2003
|
+
debugWarn('getSettings() on outbound camera track failed:', err);
|
|
2004
|
+
result = {
|
|
2005
|
+
type: 'cameraResult' as const,
|
|
2006
|
+
granted: true,
|
|
2007
|
+
camera: {
|
|
2008
|
+
label: videoTrack.label || undefined,
|
|
2009
|
+
},
|
|
2010
|
+
};
|
|
2080
2011
|
}
|
|
2012
|
+
ws.send(JSON.stringify(result));
|
|
2013
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, true, result.camera);
|
|
2081
2014
|
};
|
|
2082
2015
|
|
|
2083
2016
|
// Convert the codec mime type (e.g. "video/H264" or "video/HEVC")
|
|
@@ -2189,6 +2122,210 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2189
2122
|
safeInvoke('onCameraStats', onCameraStatsRef.current, null);
|
|
2190
2123
|
};
|
|
2191
2124
|
|
|
2125
|
+
const handleCameraRequest = (
|
|
2126
|
+
request: CameraRequest,
|
|
2127
|
+
ws: WebSocket,
|
|
2128
|
+
sender: RTCRtpSender,
|
|
2129
|
+
isCurrentAttempt: () => boolean,
|
|
2130
|
+
): Promise<void> => {
|
|
2131
|
+
// `enqueue` increments its generation synchronously, so suspended
|
|
2132
|
+
// permission work becomes stale before the newer operation starts.
|
|
2133
|
+
return cameraOperations.enqueue(async ({ isCurrent }) => {
|
|
2134
|
+
const isCurrentCameraRequest = () =>
|
|
2135
|
+
isCurrent() &&
|
|
2136
|
+
isCurrentAttempt() &&
|
|
2137
|
+
wsRef.current === ws &&
|
|
2138
|
+
outboundCameraSenderRef.current === sender;
|
|
2139
|
+
if (!isCurrentCameraRequest()) return;
|
|
2140
|
+
|
|
2141
|
+
if (!request.active) {
|
|
2142
|
+
const stream = outboundLocalStreamRef.current;
|
|
2143
|
+
try {
|
|
2144
|
+
await sender.replaceTrack(null);
|
|
2145
|
+
} catch (err) {
|
|
2146
|
+
debugWarn('replaceTrack(null) on camera detach failed:', err);
|
|
2147
|
+
}
|
|
2148
|
+
if (outboundLocalStreamRef.current === stream) {
|
|
2149
|
+
outboundLocalStreamRef.current = null;
|
|
2150
|
+
outboundCameraRequestRef.current = undefined;
|
|
2151
|
+
if (stream) stopMediaStream(stream);
|
|
2152
|
+
}
|
|
2153
|
+
stopCameraStatsPoller();
|
|
2154
|
+
if (!isCurrentCameraRequest()) return;
|
|
2155
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, false);
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true);
|
|
2160
|
+
const currentStream = outboundLocalStreamRef.current;
|
|
2161
|
+
const currentTrack = currentStream?.getVideoTracks()[0];
|
|
2162
|
+
const currentRequest = outboundCameraRequestRef.current;
|
|
2163
|
+
|
|
2164
|
+
if (
|
|
2165
|
+
currentTrack?.readyState === 'live' &&
|
|
2166
|
+
currentRequest &&
|
|
2167
|
+
!shouldReacquireCamera(currentRequest, request)
|
|
2168
|
+
) {
|
|
2169
|
+
outboundCameraRequestRef.current = request;
|
|
2170
|
+
sendGrantedCameraResult(ws, currentTrack);
|
|
2171
|
+
startCameraStatsPoller();
|
|
2172
|
+
return;
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
// A facing change always restarts browser capture. Releasing first
|
|
2176
|
+
// lets mobile browsers open the opposite camera; one-camera laptops
|
|
2177
|
+
// naturally return the same available input for the new ideal.
|
|
2178
|
+
if (currentStream) {
|
|
2179
|
+
try {
|
|
2180
|
+
await sender.replaceTrack(null);
|
|
2181
|
+
} catch (err) {
|
|
2182
|
+
debugWarn('replaceTrack(null) before camera switch failed:', err);
|
|
2183
|
+
}
|
|
2184
|
+
if (outboundLocalStreamRef.current === currentStream) {
|
|
2185
|
+
outboundLocalStreamRef.current = null;
|
|
2186
|
+
outboundCameraRequestRef.current = undefined;
|
|
2187
|
+
stopMediaStream(currentStream);
|
|
2188
|
+
}
|
|
2189
|
+
stopCameraStatsPoller();
|
|
2190
|
+
if (!isCurrentCameraRequest()) return;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== 'function') {
|
|
2194
|
+
// eslint-disable-next-line no-console
|
|
2195
|
+
console.warn(
|
|
2196
|
+
'[RemoteControl] navigator.mediaDevices.getUserMedia unavailable. ' +
|
|
2197
|
+
'getUserMedia requires a secure context (https or http://localhost). ' +
|
|
2198
|
+
'Replying cameraResult granted=false so limulator falls back to black frames.',
|
|
2199
|
+
);
|
|
2200
|
+
if (!isCurrentCameraRequest()) return;
|
|
2201
|
+
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
2202
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
let stream: MediaStream | null = null;
|
|
2207
|
+
try {
|
|
2208
|
+
stream = await navigator.mediaDevices.getUserMedia({
|
|
2209
|
+
video: cameraCaptureConstraints(request),
|
|
2210
|
+
audio: false,
|
|
2211
|
+
});
|
|
2212
|
+
} catch (err) {
|
|
2213
|
+
// eslint-disable-next-line no-console
|
|
2214
|
+
console.warn('[RemoteControl] getUserMedia denied/failed:', err);
|
|
2215
|
+
}
|
|
2216
|
+
if (!isCurrentCameraRequest()) {
|
|
2217
|
+
if (stream) stopMediaStream(stream);
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
if (!stream) {
|
|
2221
|
+
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
2222
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
const videoTrack = stream.getVideoTracks()[0] ?? null;
|
|
2227
|
+
if (!videoTrack) {
|
|
2228
|
+
stopMediaStream(stream);
|
|
2229
|
+
if (!isCurrentCameraRequest()) return;
|
|
2230
|
+
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
2231
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
try {
|
|
2236
|
+
videoTrack.contentHint = 'motion';
|
|
2237
|
+
} catch {
|
|
2238
|
+
/* older browsers don't support contentHint; ignore */
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
try {
|
|
2242
|
+
await sender.replaceTrack(videoTrack);
|
|
2243
|
+
} catch (err) {
|
|
2244
|
+
debugWarn('replaceTrack(videoTrack) failed:', err);
|
|
2245
|
+
stopMediaStream(stream);
|
|
2246
|
+
if (!isCurrentCameraRequest()) return;
|
|
2247
|
+
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
2248
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
if (!isCurrentCameraRequest()) {
|
|
2252
|
+
try {
|
|
2253
|
+
await sender.replaceTrack(null);
|
|
2254
|
+
} catch (err) {
|
|
2255
|
+
debugWarn('replaceTrack(null) for stale camera attach failed:', err);
|
|
2256
|
+
}
|
|
2257
|
+
stopMediaStream(stream);
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
try {
|
|
2262
|
+
const params = sender.getParameters();
|
|
2263
|
+
if (!params.encodings || params.encodings.length === 0) {
|
|
2264
|
+
params.encodings = [{}];
|
|
2265
|
+
}
|
|
2266
|
+
params.encodings[0].maxBitrate = 8_000_000;
|
|
2267
|
+
params.encodings[0].maxFramerate = 30;
|
|
2268
|
+
params.degradationPreference = 'maintain-framerate';
|
|
2269
|
+
await sender.setParameters(params);
|
|
2270
|
+
} catch (err) {
|
|
2271
|
+
debugWarn('setParameters on outbound camera failed:', err);
|
|
2272
|
+
}
|
|
2273
|
+
if (!isCurrentCameraRequest()) {
|
|
2274
|
+
try {
|
|
2275
|
+
await sender.replaceTrack(null);
|
|
2276
|
+
} catch (err) {
|
|
2277
|
+
debugWarn('replaceTrack(null) for stale camera finalize failed:', err);
|
|
2278
|
+
}
|
|
2279
|
+
stopMediaStream(stream);
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
outboundLocalStreamRef.current = stream;
|
|
2284
|
+
outboundCameraRequestRef.current = request;
|
|
2285
|
+
|
|
2286
|
+
videoTrack.onended = () => {
|
|
2287
|
+
if (outboundLocalStreamRef.current !== stream) return;
|
|
2288
|
+
void cameraOperations
|
|
2289
|
+
.enqueueCurrent(async ({ isCurrent: isCurrentEndedCleanup }) => {
|
|
2290
|
+
if (
|
|
2291
|
+
!isCurrentEndedCleanup() ||
|
|
2292
|
+
outboundLocalStreamRef.current !== stream ||
|
|
2293
|
+
!isCurrentAttempt() ||
|
|
2294
|
+
wsRef.current !== ws ||
|
|
2295
|
+
outboundCameraSenderRef.current !== sender
|
|
2296
|
+
) {
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
try {
|
|
2300
|
+
await sender.replaceTrack(null);
|
|
2301
|
+
} catch (err) {
|
|
2302
|
+
debugWarn('replaceTrack(null) after camera ended failed:', err);
|
|
2303
|
+
}
|
|
2304
|
+
if (
|
|
2305
|
+
!isCurrentEndedCleanup() ||
|
|
2306
|
+
outboundLocalStreamRef.current !== stream ||
|
|
2307
|
+
!isCurrentAttempt() ||
|
|
2308
|
+
wsRef.current !== ws ||
|
|
2309
|
+
outboundCameraSenderRef.current !== sender
|
|
2310
|
+
) {
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
outboundLocalStreamRef.current = null;
|
|
2314
|
+
outboundCameraRequestRef.current = undefined;
|
|
2315
|
+
stopMediaStream(stream);
|
|
2316
|
+
stopCameraStatsPoller();
|
|
2317
|
+
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
2318
|
+
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
2319
|
+
})
|
|
2320
|
+
.catch((err) => debugWarn('camera ended cleanup failed:', err));
|
|
2321
|
+
};
|
|
2322
|
+
|
|
2323
|
+
if (!isCurrentCameraRequest()) return;
|
|
2324
|
+
sendGrantedCameraResult(ws, videoTrack);
|
|
2325
|
+
startCameraStatsPoller();
|
|
2326
|
+
});
|
|
2327
|
+
};
|
|
2328
|
+
|
|
2192
2329
|
const teardownConnection = () => {
|
|
2193
2330
|
clearConnectionSuccessTimeout();
|
|
2194
2331
|
clearIceDisconnectedGrace();
|
|
@@ -2200,8 +2337,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2200
2337
|
// Drop any active outbound camera before the PC dies so the
|
|
2201
2338
|
// browser doesn't leave the camera indicator lit between
|
|
2202
2339
|
// reconnects.
|
|
2340
|
+
cameraOperations.invalidate();
|
|
2203
2341
|
stopCameraStatsPoller();
|
|
2204
2342
|
stopOutboundLocalStream();
|
|
2343
|
+
outboundCameraRequestRef.current = undefined;
|
|
2205
2344
|
outboundCameraSenderRef.current = null;
|
|
2206
2345
|
// Same for the live microphone. Manual enablement survives reconnect;
|
|
2207
2346
|
// guest demand is replayed by the host after it assigns the new session.
|
|
@@ -2427,21 +2566,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2427
2566
|
if (!isCurrentAttempt() || wsRef.current !== ws) {
|
|
2428
2567
|
return;
|
|
2429
2568
|
}
|
|
2430
|
-
// Replay the saved camera aspect for fresh connections
|
|
2431
|
-
// (initial mount, autoreconnect, sim reboot). The host's
|
|
2432
|
-
// streamer starts at its boot default (16:9 / 1920×1080)
|
|
2433
|
-
// and a missing message would mean the operator's pick
|
|
2434
|
-
// silently reverts on reconnect. We always send something
|
|
2435
|
-
// when the prop is set, even if it matches the host
|
|
2436
|
-
// default — the host short-circuits no-op rebuilds.
|
|
2437
|
-
const initialAspect = cameraAspectRef.current;
|
|
2438
|
-
if (initialAspect) {
|
|
2439
|
-
try {
|
|
2440
|
-
ws.send(JSON.stringify({ type: 'cameraAspect', aspect: initialAspect }));
|
|
2441
|
-
} catch (err) {
|
|
2442
|
-
debugWarn('initial cameraAspect send failed:', err);
|
|
2443
|
-
}
|
|
2444
|
-
}
|
|
2445
2569
|
settle(resolve);
|
|
2446
2570
|
};
|
|
2447
2571
|
|
|
@@ -2479,8 +2603,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2479
2603
|
updateStatus('WebSocket closed');
|
|
2480
2604
|
};
|
|
2481
2605
|
|
|
2482
|
-
// Request RTCConfiguration
|
|
2483
|
-
|
|
2606
|
+
// Request RTCConfiguration. The reply doubles as the capability
|
|
2607
|
+
// handshake: camera-capable Android servers advertise
|
|
2608
|
+
// `cameraSupported: true` alongside the configuration.
|
|
2609
|
+
const rtcConfigPromise = new Promise<{
|
|
2610
|
+
rtcConfiguration: RTCConfiguration;
|
|
2611
|
+
cameraSupported: boolean;
|
|
2612
|
+
}>((resolve, reject) => {
|
|
2484
2613
|
const timeoutId = window.setTimeout(() => reject(new Error('RTCConfiguration timeout')), 30000);
|
|
2485
2614
|
|
|
2486
2615
|
const messageHandler = (event: MessageEvent) => {
|
|
@@ -2489,7 +2618,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2489
2618
|
if (message.type === 'rtcConfiguration') {
|
|
2490
2619
|
window.clearTimeout(timeoutId);
|
|
2491
2620
|
ws.removeEventListener('message', messageHandler);
|
|
2492
|
-
resolve(
|
|
2621
|
+
resolve({
|
|
2622
|
+
rtcConfiguration: message.rtcConfiguration,
|
|
2623
|
+
cameraSupported: message.cameraSupported === true,
|
|
2624
|
+
});
|
|
2493
2625
|
}
|
|
2494
2626
|
} catch (e) {
|
|
2495
2627
|
window.clearTimeout(timeoutId);
|
|
@@ -2508,7 +2640,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2508
2640
|
);
|
|
2509
2641
|
});
|
|
2510
2642
|
|
|
2511
|
-
const rtcConfig
|
|
2643
|
+
const { rtcConfiguration: rtcConfig, cameraSupported: serverCameraSupported } =
|
|
2644
|
+
await rtcConfigPromise;
|
|
2512
2645
|
if (!isCurrentAttempt() || wsRef.current !== ws) {
|
|
2513
2646
|
return;
|
|
2514
2647
|
}
|
|
@@ -2518,17 +2651,20 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2518
2651
|
peerConnection.addTransceiver('audio', { direction: 'recvonly' });
|
|
2519
2652
|
const videoTransceiver = peerConnection.addTransceiver('video', { direction: 'recvonly' });
|
|
2520
2653
|
|
|
2654
|
+
// iOS always supports camera/mic injection; Android servers
|
|
2655
|
+
// advertise it through `cameraSupported` (Android 15 and newer).
|
|
2656
|
+
// Never offer the extra sendonly m-lines to non-capable Android
|
|
2657
|
+
// servers — older servers cannot negotiate them safely.
|
|
2658
|
+
const canSendMedia = platform === 'ios' || serverCameraSupported;
|
|
2659
|
+
|
|
2521
2660
|
// Pre-allocate a sendonly slot for the user's camera so the
|
|
2522
|
-
//
|
|
2661
|
+
// device can pull frames later without renegotiating SDP.
|
|
2523
2662
|
// Allocating up-front means we don't have to renegotiate the
|
|
2524
|
-
// SDP when the
|
|
2525
|
-
//
|
|
2526
|
-
//
|
|
2527
|
-
// `replaceTrack`.
|
|
2528
|
-
// iOS only: Android has no camera consumer, and the extra
|
|
2529
|
-
// m=video line crashes Android 14's scrcpy/libwebrtc.
|
|
2663
|
+
// SDP when the device app actually opens its camera session —
|
|
2664
|
+
// the codec/SSRC negotiation happens once, here, and turning
|
|
2665
|
+
// the camera on/off later is just a `replaceTrack`.
|
|
2530
2666
|
const outboundCameraTransceiver =
|
|
2531
|
-
|
|
2667
|
+
canSendMedia ?
|
|
2532
2668
|
peerConnection.addTransceiver('video', {
|
|
2533
2669
|
direction: 'sendonly',
|
|
2534
2670
|
})
|
|
@@ -2537,10 +2673,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2537
2673
|
|
|
2538
2674
|
// Same pre-allocation for the live microphone: a sendonly audio
|
|
2539
2675
|
// slot negotiated once at connection setup, so toggling the mic
|
|
2540
|
-
// later is just `replaceTrack` — no SDP renegotiation.
|
|
2541
|
-
// like the camera.
|
|
2676
|
+
// later is just `replaceTrack` — no SDP renegotiation.
|
|
2542
2677
|
const outboundMicTransceiver =
|
|
2543
|
-
|
|
2678
|
+
canSendMedia ?
|
|
2544
2679
|
peerConnection.addTransceiver('audio', {
|
|
2545
2680
|
direction: 'sendonly',
|
|
2546
2681
|
})
|
|
@@ -2649,6 +2784,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2649
2784
|
platform,
|
|
2650
2785
|
baseIntervalMs: axPollIntervalMs,
|
|
2651
2786
|
maxBackoffMs: axMaxBackoffMs,
|
|
2787
|
+
androidElementTreeOptions,
|
|
2652
2788
|
send: (payload) => {
|
|
2653
2789
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return false;
|
|
2654
2790
|
try {
|
|
@@ -2934,244 +3070,26 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2934
3070
|
pendingScreenshotRejectersRef.current.delete(message.id);
|
|
2935
3071
|
break;
|
|
2936
3072
|
case 'cameraRequest': {
|
|
2937
|
-
// Only
|
|
2938
|
-
// transceiver; without it there is nothing to
|
|
2939
|
-
// ignore camera requests entirely — no
|
|
2940
|
-
// no onCameraDemandChange callbacks.
|
|
2941
|
-
// silent, as the API docs promise.
|
|
2942
|
-
if (platform !== 'ios' || !outboundCameraSenderRef.current) {
|
|
2943
|
-
break;
|
|
2944
|
-
}
|
|
2945
|
-
const active = message.active === true;
|
|
2946
|
-
// Bump up front so any earlier in-flight handler bails.
|
|
2947
|
-
const cameraGeneration = ++cameraRequestGenerationRef.current;
|
|
2948
|
-
const isCurrentCameraRequest = () =>
|
|
2949
|
-
isCurrentAttempt() &&
|
|
2950
|
-
wsRef.current === ws &&
|
|
2951
|
-
cameraGeneration === cameraRequestGenerationRef.current;
|
|
2952
|
-
// Log unconditionally — this is one of the few places we
|
|
2953
|
-
// hand control to the browser's permission UI, and a
|
|
2954
|
-
// silent failure here looks identical to "limulator
|
|
2955
|
-
// never asked" from the user's point of view.
|
|
2956
|
-
// eslint-disable-next-line no-console
|
|
2957
|
-
console.info('[RemoteControl] cameraRequest received, active=', active);
|
|
2958
|
-
if (!active) {
|
|
2959
|
-
// Sim no longer wants frames. Drop our local track and
|
|
2960
|
-
// shut the browser's camera green light off.
|
|
2961
|
-
const sender = outboundCameraSenderRef.current;
|
|
2962
|
-
if (sender) {
|
|
2963
|
-
try {
|
|
2964
|
-
await sender.replaceTrack(null);
|
|
2965
|
-
} catch (err) {
|
|
2966
|
-
debugWarn('replaceTrack(null) on camera detach failed:', err);
|
|
2967
|
-
}
|
|
2968
|
-
}
|
|
2969
|
-
stopCameraStatsPoller();
|
|
2970
|
-
stopOutboundLocalStream();
|
|
2971
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, false);
|
|
2972
|
-
break;
|
|
2973
|
-
}
|
|
2974
|
-
// Sim is asking for camera. Ask the browser; the user's
|
|
2975
|
-
// prompt response is reported back to the host via a
|
|
2976
|
-
// `cameraResult` message so it can swap to a
|
|
2977
|
-
// black-frame fallback on denial.
|
|
2978
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true);
|
|
2979
|
-
if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== 'function') {
|
|
2980
|
-
// Likely an insecure context (http on a non-localhost
|
|
2981
|
-
// origin) — Chrome strips `mediaDevices` off
|
|
2982
|
-
// `navigator` in that case and the only signal is
|
|
2983
|
-
// this undefined check.
|
|
2984
|
-
// eslint-disable-next-line no-console
|
|
2985
|
-
console.warn(
|
|
2986
|
-
'[RemoteControl] navigator.mediaDevices.getUserMedia unavailable. ' +
|
|
2987
|
-
'getUserMedia requires a secure context (https or http://localhost). ' +
|
|
2988
|
-
'Replying cameraResult granted=false so limulator falls back to black frames.',
|
|
2989
|
-
);
|
|
2990
|
-
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
2991
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
2992
|
-
break;
|
|
2993
|
-
}
|
|
2994
|
-
let stream: MediaStream | null = null;
|
|
2995
|
-
try {
|
|
2996
|
-
// Capture at the webcam's *native* resolution by
|
|
2997
|
-
// default (no width/height constraints). When the
|
|
2998
|
-
// host app has picked an explicit cap via
|
|
2999
|
-
// `cameraResolutionCap`, we honour it here. Frame
|
|
3000
|
-
// rate is always capped to 30 to match the
|
|
3001
|
-
// simulator pool's pacing.
|
|
3002
|
-
stream = await navigator.mediaDevices.getUserMedia({
|
|
3003
|
-
video: cameraCapToConstraints(cameraResolutionCapRef.current),
|
|
3004
|
-
audio: false,
|
|
3005
|
-
});
|
|
3006
|
-
} catch (err) {
|
|
3007
|
-
// Surface unconditionally — the user is the only one
|
|
3008
|
-
// who can fix this (permission denied, no device,
|
|
3009
|
-
// dismissed prompt, etc.). `debugWarn` alone would
|
|
3010
|
-
// hide it in normal builds.
|
|
3011
|
-
// eslint-disable-next-line no-console
|
|
3012
|
-
console.warn('[RemoteControl] getUserMedia denied/failed:', err);
|
|
3013
|
-
}
|
|
3014
|
-
if (!isCurrentCameraRequest()) {
|
|
3015
|
-
// Superseded during the prompt; drop the stream we got.
|
|
3016
|
-
if (stream) stopMediaStream(stream);
|
|
3017
|
-
return;
|
|
3018
|
-
}
|
|
3019
|
-
if (!stream) {
|
|
3020
|
-
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
3021
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
3022
|
-
break;
|
|
3023
|
-
}
|
|
3024
|
-
// Replace any previous local stream (e.g. an earlier
|
|
3025
|
-
// cameraRequest that resolved with a different device)
|
|
3026
|
-
// before we install the new tracks.
|
|
3027
|
-
stopOutboundLocalStream();
|
|
3028
|
-
outboundLocalStreamRef.current = stream;
|
|
3073
|
+
// Only camera-capable sessions allocate the sendonly
|
|
3074
|
+
// outbound-camera transceiver; without it there is nothing to
|
|
3075
|
+
// feed the device, so ignore camera requests entirely — no
|
|
3076
|
+
// getUserMedia prompt and no onCameraDemandChange callbacks.
|
|
3029
3077
|
const sender = outboundCameraSenderRef.current;
|
|
3030
|
-
|
|
3031
|
-
if (!sender || !videoTrack) {
|
|
3032
|
-
if (stream) stopMediaStream(stream);
|
|
3033
|
-
outboundLocalStreamRef.current = null;
|
|
3034
|
-
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
3035
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
3078
|
+
if (!sender) {
|
|
3036
3079
|
break;
|
|
3037
3080
|
}
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
debugWarn('replaceTrack(videoTrack) failed:', err);
|
|
3042
|
-
stopMediaStream(stream);
|
|
3043
|
-
outboundLocalStreamRef.current = null;
|
|
3044
|
-
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
3045
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
3081
|
+
const request = parseCameraRequest(message);
|
|
3082
|
+
if (!request) {
|
|
3083
|
+
debugWarn('Received invalid cameraRequest:', message);
|
|
3046
3084
|
break;
|
|
3047
3085
|
}
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
await sender.replaceTrack(null);
|
|
3052
|
-
} catch (err) {
|
|
3053
|
-
debugWarn('replaceTrack(null) on stale camera attach failed:', err);
|
|
3054
|
-
}
|
|
3055
|
-
stopMediaStream(stream);
|
|
3056
|
-
if (outboundLocalStreamRef.current === stream) {
|
|
3057
|
-
outboundLocalStreamRef.current = null;
|
|
3058
|
-
}
|
|
3059
|
-
return;
|
|
3060
|
-
}
|
|
3061
|
-
// Tell the encoder this is real motion content (a
|
|
3062
|
-
// physical camera), not text/slides. With `'motion'`
|
|
3063
|
-
// VideoToolbox / libvpx pick latency-friendly tuning
|
|
3064
|
-
// (no extra B-frames, shorter GoP smoothing). Set
|
|
3065
|
-
// before the first frame so the encoder init reads it.
|
|
3066
|
-
try {
|
|
3067
|
-
videoTrack.contentHint = 'motion';
|
|
3068
|
-
} catch {
|
|
3069
|
-
/* older browsers don't support contentHint; ignore */
|
|
3070
|
-
}
|
|
3071
|
-
// Bound the outbound bitrate generously and let
|
|
3072
|
-
// WebRTC's congestion control (BWE) find the floor.
|
|
3073
|
-
// 8 Mbps is comfortable for native 1080p30 webcam
|
|
3074
|
-
// content over LAN — the encoder will use far less when
|
|
3075
|
-
// the scene is static, and BWE will throttle if a
|
|
3076
|
-
// hop is constrained. Pair with
|
|
3077
|
-
// `maintain-framerate` so the quality scaler steps
|
|
3078
|
-
// down resolution before dropping frames, matching how
|
|
3079
|
-
// Meet/Zoom degrade.
|
|
3080
|
-
try {
|
|
3081
|
-
const params = sender.getParameters();
|
|
3082
|
-
if (!params.encodings || params.encodings.length === 0) {
|
|
3083
|
-
params.encodings = [{}];
|
|
3084
|
-
}
|
|
3085
|
-
params.encodings[0].maxBitrate = 8_000_000;
|
|
3086
|
-
params.encodings[0].maxFramerate = 30;
|
|
3087
|
-
params.degradationPreference = 'maintain-framerate';
|
|
3088
|
-
await sender.setParameters(params);
|
|
3089
|
-
} catch (err) {
|
|
3090
|
-
debugWarn('setParameters on outbound camera failed:', err);
|
|
3091
|
-
}
|
|
3092
|
-
// Surface the actual captured geometry to the host so
|
|
3093
|
-
// it can size its IOSurface pool / picture-format
|
|
3094
|
-
// metadata to match. `getSettings()` returns what the
|
|
3095
|
-
// browser actually picked — which may differ from any
|
|
3096
|
-
// hints we sent in the constraints — including the
|
|
3097
|
-
// selected `deviceId` / `label` (useful when a user has
|
|
3098
|
-
// multiple cameras and we eventually expose a picker).
|
|
3099
|
-
let cameraMetadata: {
|
|
3100
|
-
width?: number;
|
|
3101
|
-
height?: number;
|
|
3102
|
-
frameRate?: number;
|
|
3103
|
-
deviceId?: string;
|
|
3104
|
-
label?: string;
|
|
3105
|
-
facingMode?: string;
|
|
3106
|
-
} = {};
|
|
3107
|
-
try {
|
|
3108
|
-
const settings = videoTrack.getSettings();
|
|
3109
|
-
cameraMetadata = {
|
|
3110
|
-
width: settings.width,
|
|
3111
|
-
height: settings.height,
|
|
3112
|
-
frameRate: settings.frameRate,
|
|
3113
|
-
deviceId: settings.deviceId,
|
|
3114
|
-
label: videoTrack.label || undefined,
|
|
3115
|
-
facingMode: settings.facingMode,
|
|
3116
|
-
};
|
|
3117
|
-
// eslint-disable-next-line no-console
|
|
3118
|
-
console.info(
|
|
3119
|
-
`[RemoteControl] camera capture: ${cameraMetadata.width}x${cameraMetadata.height}` +
|
|
3120
|
-
` @ ${cameraMetadata.frameRate ?? '?'}fps` +
|
|
3121
|
-
(cameraMetadata.label ? ` — ${cameraMetadata.label}` : ''),
|
|
3122
|
-
);
|
|
3123
|
-
} catch (err) {
|
|
3124
|
-
debugWarn('getSettings() on outbound camera track failed:', err);
|
|
3125
|
-
}
|
|
3126
|
-
// If the browser revokes the track later (extension,
|
|
3127
|
-
// user clicks Stop in the camera tab UI, etc.), notify
|
|
3128
|
-
// the host so it can switch to black frames.
|
|
3129
|
-
videoTrack.onended = () => {
|
|
3130
|
-
if (outboundLocalStreamRef.current !== stream) return;
|
|
3131
|
-
stopCameraStatsPoller();
|
|
3132
|
-
stopOutboundLocalStream();
|
|
3133
|
-
if (wsRef.current === ws && ws.readyState === WebSocket.OPEN) {
|
|
3134
|
-
ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
|
|
3135
|
-
}
|
|
3136
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
|
|
3137
|
-
};
|
|
3138
|
-
if (!isCurrentCameraRequest()) {
|
|
3139
|
-
// Superseded during setParameters; detach and bail.
|
|
3140
|
-
videoTrack.onended = null;
|
|
3141
|
-
try {
|
|
3142
|
-
await sender.replaceTrack(null);
|
|
3143
|
-
} catch (err) {
|
|
3144
|
-
debugWarn('replaceTrack(null) on stale camera finalize failed:', err);
|
|
3145
|
-
}
|
|
3146
|
-
stopMediaStream(stream);
|
|
3147
|
-
if (outboundLocalStreamRef.current === stream) {
|
|
3148
|
-
outboundLocalStreamRef.current = null;
|
|
3149
|
-
}
|
|
3150
|
-
return;
|
|
3151
|
-
}
|
|
3152
|
-
ws.send(
|
|
3153
|
-
JSON.stringify({
|
|
3154
|
-
type: 'cameraResult',
|
|
3155
|
-
granted: true,
|
|
3156
|
-
// Forward what the browser actually captured so the
|
|
3157
|
-
// host can size its IOSurface pool, log the device,
|
|
3158
|
-
// and (eventually) surface a status pill / picker.
|
|
3159
|
-
camera: cameraMetadata,
|
|
3160
|
-
}),
|
|
3161
|
-
);
|
|
3162
|
-
safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, true, cameraMetadata);
|
|
3163
|
-
// Kick off the per-second outbound stats sampler. We do
|
|
3164
|
-
// this *after* `setParameters` so the first sample
|
|
3165
|
-
// already sees the encoder under its final bitrate /
|
|
3166
|
-
// degradation policy, and *after* the host-side
|
|
3167
|
-
// attachInboundTrack will have wired up (the
|
|
3168
|
-
// cameraResult ACK is what triggers it on the host),
|
|
3169
|
-
// so framesEncoded starts climbing immediately.
|
|
3170
|
-
startCameraStatsPoller();
|
|
3086
|
+
// eslint-disable-next-line no-console
|
|
3087
|
+
console.info('[RemoteControl] cameraRequest received:', request.active, request.facingMode);
|
|
3088
|
+
await handleCameraRequest(request, ws, sender, isCurrentAttempt);
|
|
3171
3089
|
break;
|
|
3172
3090
|
}
|
|
3173
3091
|
case 'microphoneRequest': {
|
|
3174
|
-
if (
|
|
3092
|
+
if (!outboundMicSenderRef.current) {
|
|
3175
3093
|
break;
|
|
3176
3094
|
}
|
|
3177
3095
|
const active = message.active === true;
|
|
@@ -3300,41 +3218,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3300
3218
|
start();
|
|
3301
3219
|
};
|
|
3302
3220
|
|
|
3303
|
-
// Re-apply the resolution cap on the currently-sending track
|
|
3304
|
-
// whenever the host app changes its preference. Skips when no
|
|
3305
|
-
// camera is active — the next `getUserMedia` will pick up the
|
|
3306
|
-
// new value via `cameraCapToConstraints` automatically.
|
|
3307
|
-
useEffect(() => {
|
|
3308
|
-
const stream = outboundLocalStreamRef.current;
|
|
3309
|
-
if (!stream) return;
|
|
3310
|
-
const track = stream.getVideoTracks()[0];
|
|
3311
|
-
if (!track) return;
|
|
3312
|
-
const constraints = cameraCapToConstraints(cameraResolutionCap);
|
|
3313
|
-
track.applyConstraints(constraints).catch((err) => {
|
|
3314
|
-
debugWarn('applyConstraints for new camera cap failed:', err);
|
|
3315
|
-
});
|
|
3316
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3317
|
-
}, [cameraResolutionCap]);
|
|
3318
|
-
|
|
3319
|
-
// Push the aspect preference to the host whenever it changes.
|
|
3320
|
-
// The host rebuilds its IOSurface pool and bumps `pool_generation`
|
|
3321
|
-
// on its end; the dylib re-handshakes on next sem_wait. No
|
|
3322
|
-
// peer-connection renegotiation is needed — the aspect change is
|
|
3323
|
-
// purely about the pixel buffer dimensions iOS apps observe, not
|
|
3324
|
-
// about WebRTC track layout. The `cameraAspectRef` is mirrored
|
|
3325
|
-
// higher up so the WS `onopen` reconnect path can replay the
|
|
3326
|
-
// latest value on fresh connections.
|
|
3327
|
-
useEffect(() => {
|
|
3328
|
-
if (!cameraAspect) return;
|
|
3329
|
-
const ws = wsRef.current;
|
|
3330
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
3331
|
-
try {
|
|
3332
|
-
ws.send(JSON.stringify({ type: 'cameraAspect', aspect: cameraAspect }));
|
|
3333
|
-
} catch (err) {
|
|
3334
|
-
debugWarn('cameraAspect send failed:', err);
|
|
3335
|
-
}
|
|
3336
|
-
}, [cameraAspect]);
|
|
3337
|
-
|
|
3338
3221
|
// Drive the manual override without interrupting guest-driven demand.
|
|
3339
3222
|
useEffect(() => {
|
|
3340
3223
|
if (isMicrophoneWanted()) {
|