@limrun/ui 0.14.2 → 0.15.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/dist/components/remote-control.d.ts +14 -15
- package/dist/index.cjs +1 -1
- package/dist/index.js +1148 -1129
- package/package.json +1 -1
- package/src/components/remote-control.tsx +89 -55
package/package.json
CHANGED
|
@@ -176,8 +176,9 @@ interface RemoteControlProps {
|
|
|
176
176
|
* mode. Use it to render a richer status indicator (e.g.
|
|
177
177
|
* "Camera · 1920×1080 · 30 fps · FaceTime HD").
|
|
178
178
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
179
|
+
* iOS instances always fire this callback. Android instances fire it
|
|
180
|
+
* when the server supports camera injection (Android 15+); older
|
|
181
|
+
* Android instances have no camera-injector path and stay silent.
|
|
181
182
|
*/
|
|
182
183
|
onCameraDemandChange?: (active: boolean, granted?: boolean, camera?: CameraCaptureInfo) => void;
|
|
183
184
|
|
|
@@ -235,25 +236,23 @@ interface RemoteControlProps {
|
|
|
235
236
|
cameraAspect?: CameraAspect;
|
|
236
237
|
|
|
237
238
|
/**
|
|
238
|
-
*
|
|
239
|
+
* When true, capture the user's microphone via
|
|
239
240
|
* `getUserMedia({ audio: true })` and stream it live into the
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
* `onMicrophoneStateChange`. Ignored on Android instances.
|
|
241
|
+
* device's mock microphone. This is a manual override: apps
|
|
242
|
+
* that actively consume microphone input also trigger capture
|
|
243
|
+
* automatically, like camera demand. The component handles the
|
|
244
|
+
* browser permission prompt, WebRTC track attachment, and
|
|
245
|
+
* `microphoneStart`/`microphoneStop` signaling. Progress and failures
|
|
246
|
+
* are reported via `onMicrophoneStateChange`. Ignored on Android
|
|
247
|
+
* servers without camera/mic injection support (Android 14 and older).
|
|
248
248
|
*/
|
|
249
249
|
microphoneEnabled?: boolean;
|
|
250
250
|
|
|
251
251
|
/**
|
|
252
252
|
* Fires whenever the live-microphone pipeline changes state:
|
|
253
|
-
* after
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* the mic button's on/off/error state.
|
|
253
|
+
* after a manual or guest-demanded attempt resolves, when the host
|
|
254
|
+
* acknowledges or rejects the stream, when the browser revokes the
|
|
255
|
+
* track mid-stream, and when capture stops.
|
|
257
256
|
*/
|
|
258
257
|
onMicrophoneStateChange?: (state: MicrophoneState) => void;
|
|
259
258
|
}
|
|
@@ -642,14 +641,11 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
642
641
|
packetsSent?: number;
|
|
643
642
|
packetsLost?: number;
|
|
644
643
|
} | null>(null);
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
// track rides a pre-allocated sendonly audio transceiver so
|
|
648
|
-
// enabling/disabling is just a `replaceTrack`, plus a
|
|
649
|
-
// `microphoneStart`/`microphoneStop` WS signal so the host's
|
|
650
|
-
// audio state machine switches its source.
|
|
644
|
+
// Outbound microphone state (iOS only). The manual prop and guest-driven
|
|
645
|
+
// microphoneRequest signals share one pre-allocated audio transceiver.
|
|
651
646
|
const microphoneEnabledRef = useRef(microphoneEnabled);
|
|
652
647
|
microphoneEnabledRef.current = microphoneEnabled;
|
|
648
|
+
const microphoneDemandActiveRef = useRef(false);
|
|
653
649
|
const onMicrophoneStateChangeRef = useRef(onMicrophoneStateChange);
|
|
654
650
|
onMicrophoneStateChangeRef.current = onMicrophoneStateChange;
|
|
655
651
|
const outboundMicSenderRef = useRef<RTCRtpSender | null>(null);
|
|
@@ -1921,6 +1917,14 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1921
1917
|
return next;
|
|
1922
1918
|
};
|
|
1923
1919
|
|
|
1920
|
+
const isMicrophoneWanted = () => microphoneEnabledRef.current || microphoneDemandActiveRef.current;
|
|
1921
|
+
|
|
1922
|
+
const sendMicrophoneStart = (ws: WebSocket) => {
|
|
1923
|
+
const id = `ui-mic-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
1924
|
+
micStartPendingIdRef.current = id;
|
|
1925
|
+
ws.send(JSON.stringify({ type: 'microphoneStart', id }));
|
|
1926
|
+
};
|
|
1927
|
+
|
|
1924
1928
|
// Capture the user's microphone and stream it to the simulator's
|
|
1925
1929
|
// mock microphone. Safe to call redundantly: bails when already
|
|
1926
1930
|
// attached, when the connection isn't up yet (the ICE-connected
|
|
@@ -1929,13 +1933,14 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1929
1933
|
// handler firing on both `connected` and `completed`) must not
|
|
1930
1934
|
// cancel an attach already in flight; only detach/teardown cancel.
|
|
1931
1935
|
const attachMicrophone = (): Promise<void> => {
|
|
1932
|
-
|
|
1936
|
+
// No platform gate: attachMicrophoneOp bails when the session never
|
|
1937
|
+
// allocated a sendonly mic transceiver (non-capable servers).
|
|
1933
1938
|
return runMicOp(() => attachMicrophoneOp());
|
|
1934
1939
|
};
|
|
1935
1940
|
|
|
1936
1941
|
const attachMicrophoneOp = async () => {
|
|
1937
1942
|
const generation = micGenerationRef.current;
|
|
1938
|
-
const isCurrent = () => generation === micGenerationRef.current &&
|
|
1943
|
+
const isCurrent = () => generation === micGenerationRef.current && isMicrophoneWanted();
|
|
1939
1944
|
const reportMicError = (error: string) => {
|
|
1940
1945
|
safeInvoke('onMicrophoneStateChange', onMicrophoneStateChangeRef.current, {
|
|
1941
1946
|
active: false,
|
|
@@ -2007,9 +2012,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2007
2012
|
if (outboundMicStreamRef.current !== stream) return;
|
|
2008
2013
|
void detachMicrophone('microphone capture ended by the browser');
|
|
2009
2014
|
};
|
|
2010
|
-
|
|
2011
|
-
micStartPendingIdRef.current = id;
|
|
2012
|
-
ws.send(JSON.stringify({ type: 'microphoneStart', id }));
|
|
2015
|
+
sendMicrophoneStart(ws);
|
|
2013
2016
|
safeInvoke('onMicrophoneStateChange', onMicrophoneStateChangeRef.current, {
|
|
2014
2017
|
active: true,
|
|
2015
2018
|
label: audioTrack.label || undefined,
|
|
@@ -2203,10 +2206,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2203
2206
|
stopCameraStatsPoller();
|
|
2204
2207
|
stopOutboundLocalStream();
|
|
2205
2208
|
outboundCameraSenderRef.current = null;
|
|
2206
|
-
// Same for the live microphone.
|
|
2207
|
-
//
|
|
2208
|
-
// is still on, the post-reconnect hook re-attaches.
|
|
2209
|
+
// Same for the live microphone. Manual enablement survives reconnect;
|
|
2210
|
+
// guest demand is replayed by the host after it assigns the new session.
|
|
2209
2211
|
++micGenerationRef.current;
|
|
2212
|
+
microphoneDemandActiveRef.current = false;
|
|
2210
2213
|
micStartPendingIdRef.current = null;
|
|
2211
2214
|
if (outboundMicStreamRef.current) {
|
|
2212
2215
|
stopMediaStream(outboundMicStreamRef.current);
|
|
@@ -2479,8 +2482,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2479
2482
|
updateStatus('WebSocket closed');
|
|
2480
2483
|
};
|
|
2481
2484
|
|
|
2482
|
-
// Request RTCConfiguration
|
|
2483
|
-
|
|
2485
|
+
// Request RTCConfiguration. The reply doubles as the capability
|
|
2486
|
+
// handshake: camera-capable Android servers advertise
|
|
2487
|
+
// `cameraSupported: true` alongside the configuration.
|
|
2488
|
+
const rtcConfigPromise = new Promise<{
|
|
2489
|
+
rtcConfiguration: RTCConfiguration;
|
|
2490
|
+
cameraSupported: boolean;
|
|
2491
|
+
}>((resolve, reject) => {
|
|
2484
2492
|
const timeoutId = window.setTimeout(() => reject(new Error('RTCConfiguration timeout')), 30000);
|
|
2485
2493
|
|
|
2486
2494
|
const messageHandler = (event: MessageEvent) => {
|
|
@@ -2489,7 +2497,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2489
2497
|
if (message.type === 'rtcConfiguration') {
|
|
2490
2498
|
window.clearTimeout(timeoutId);
|
|
2491
2499
|
ws.removeEventListener('message', messageHandler);
|
|
2492
|
-
resolve(
|
|
2500
|
+
resolve({
|
|
2501
|
+
rtcConfiguration: message.rtcConfiguration,
|
|
2502
|
+
cameraSupported: message.cameraSupported === true,
|
|
2503
|
+
});
|
|
2493
2504
|
}
|
|
2494
2505
|
} catch (e) {
|
|
2495
2506
|
window.clearTimeout(timeoutId);
|
|
@@ -2508,7 +2519,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2508
2519
|
);
|
|
2509
2520
|
});
|
|
2510
2521
|
|
|
2511
|
-
const rtcConfig
|
|
2522
|
+
const { rtcConfiguration: rtcConfig, cameraSupported: serverCameraSupported } =
|
|
2523
|
+
await rtcConfigPromise;
|
|
2512
2524
|
if (!isCurrentAttempt() || wsRef.current !== ws) {
|
|
2513
2525
|
return;
|
|
2514
2526
|
}
|
|
@@ -2518,17 +2530,20 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2518
2530
|
peerConnection.addTransceiver('audio', { direction: 'recvonly' });
|
|
2519
2531
|
const videoTransceiver = peerConnection.addTransceiver('video', { direction: 'recvonly' });
|
|
2520
2532
|
|
|
2533
|
+
// iOS always supports camera/mic injection; Android servers
|
|
2534
|
+
// advertise it through `cameraSupported` (Android 15 and newer).
|
|
2535
|
+
// Never offer the extra sendonly m-lines to non-capable Android
|
|
2536
|
+
// servers — older servers cannot negotiate them safely.
|
|
2537
|
+
const canSendMedia = platform === 'ios' || serverCameraSupported;
|
|
2538
|
+
|
|
2521
2539
|
// Pre-allocate a sendonly slot for the user's camera so the
|
|
2522
|
-
//
|
|
2540
|
+
// device can pull frames later without renegotiating SDP.
|
|
2523
2541
|
// 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.
|
|
2542
|
+
// SDP when the device app actually opens its camera session —
|
|
2543
|
+
// the codec/SSRC negotiation happens once, here, and turning
|
|
2544
|
+
// the camera on/off later is just a `replaceTrack`.
|
|
2530
2545
|
const outboundCameraTransceiver =
|
|
2531
|
-
|
|
2546
|
+
canSendMedia ?
|
|
2532
2547
|
peerConnection.addTransceiver('video', {
|
|
2533
2548
|
direction: 'sendonly',
|
|
2534
2549
|
})
|
|
@@ -2537,10 +2552,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2537
2552
|
|
|
2538
2553
|
// Same pre-allocation for the live microphone: a sendonly audio
|
|
2539
2554
|
// slot negotiated once at connection setup, so toggling the mic
|
|
2540
|
-
// later is just `replaceTrack` — no SDP renegotiation.
|
|
2541
|
-
// like the camera.
|
|
2555
|
+
// later is just `replaceTrack` — no SDP renegotiation.
|
|
2542
2556
|
const outboundMicTransceiver =
|
|
2543
|
-
|
|
2557
|
+
canSendMedia ?
|
|
2544
2558
|
peerConnection.addTransceiver('audio', {
|
|
2545
2559
|
direction: 'sendonly',
|
|
2546
2560
|
})
|
|
@@ -2770,7 +2784,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2770
2784
|
clearIceDisconnectedGrace();
|
|
2771
2785
|
// Re-attach the live microphone across reconnects (or apply a
|
|
2772
2786
|
// prop that was flipped on before the connection was up).
|
|
2773
|
-
if (
|
|
2787
|
+
if (isMicrophoneWanted() && !outboundMicStreamRef.current) {
|
|
2774
2788
|
void attachMicrophone();
|
|
2775
2789
|
}
|
|
2776
2790
|
return;
|
|
@@ -2934,12 +2948,11 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2934
2948
|
pendingScreenshotRejectersRef.current.delete(message.id);
|
|
2935
2949
|
break;
|
|
2936
2950
|
case 'cameraRequest': {
|
|
2937
|
-
// Only
|
|
2938
|
-
// transceiver; without it there is nothing to
|
|
2939
|
-
// ignore camera requests entirely — no
|
|
2940
|
-
// no onCameraDemandChange callbacks.
|
|
2941
|
-
|
|
2942
|
-
if (platform !== 'ios' || !outboundCameraSenderRef.current) {
|
|
2951
|
+
// Only camera-capable sessions allocate the sendonly
|
|
2952
|
+
// outbound-camera transceiver; without it there is nothing to
|
|
2953
|
+
// feed the device, so ignore camera requests entirely — no
|
|
2954
|
+
// getUserMedia prompt and no onCameraDemandChange callbacks.
|
|
2955
|
+
if (!outboundCameraSenderRef.current) {
|
|
2943
2956
|
break;
|
|
2944
2957
|
}
|
|
2945
2958
|
const active = message.active === true;
|
|
@@ -3170,6 +3183,29 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3170
3183
|
startCameraStatsPoller();
|
|
3171
3184
|
break;
|
|
3172
3185
|
}
|
|
3186
|
+
case 'microphoneRequest': {
|
|
3187
|
+
if (!outboundMicSenderRef.current) {
|
|
3188
|
+
break;
|
|
3189
|
+
}
|
|
3190
|
+
const active = message.active === true;
|
|
3191
|
+
microphoneDemandActiveRef.current = active;
|
|
3192
|
+
// eslint-disable-next-line no-console
|
|
3193
|
+
console.info('[RemoteControl] microphoneRequest received, active=', active);
|
|
3194
|
+
if (!active) {
|
|
3195
|
+
if (!microphoneEnabledRef.current) {
|
|
3196
|
+
await detachMicrophone();
|
|
3197
|
+
}
|
|
3198
|
+
break;
|
|
3199
|
+
}
|
|
3200
|
+
if (outboundMicStreamRef.current) {
|
|
3201
|
+
// A manual stream may already be attached. Re-select it on the
|
|
3202
|
+
// host because file playback can have replaced its source.
|
|
3203
|
+
sendMicrophoneStart(ws);
|
|
3204
|
+
break;
|
|
3205
|
+
}
|
|
3206
|
+
await attachMicrophone();
|
|
3207
|
+
break;
|
|
3208
|
+
}
|
|
3173
3209
|
case 'microphoneStartResult': {
|
|
3174
3210
|
if (micStartPendingIdRef.current === null || message.id !== micStartPendingIdRef.current) {
|
|
3175
3211
|
break;
|
|
@@ -3312,11 +3348,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3312
3348
|
}
|
|
3313
3349
|
}, [cameraAspect]);
|
|
3314
3350
|
|
|
3315
|
-
// Drive the
|
|
3316
|
-
// When flipped on before the connection is up, attachMicrophone
|
|
3317
|
-
// bails and the ICE-connected handler picks it up instead.
|
|
3351
|
+
// Drive the manual override without interrupting guest-driven demand.
|
|
3318
3352
|
useEffect(() => {
|
|
3319
|
-
if (
|
|
3353
|
+
if (isMicrophoneWanted()) {
|
|
3320
3354
|
void attachMicrophone();
|
|
3321
3355
|
} else {
|
|
3322
3356
|
void detachMicrophone();
|