@limrun/ui 0.14.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limrun/ui",
3
- "version": "0.14.3",
3
+ "version": "0.15.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "tag": "next"
@@ -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
- * Only iOS instances ever fire this callback; Android instances
180
- * have no camera-injector path and stay silent.
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,14 +236,15 @@ interface RemoteControlProps {
235
236
  cameraAspect?: CameraAspect;
236
237
 
237
238
  /**
238
- * iOS only: when true, capture the user's microphone via
239
+ * When true, capture the user's microphone via
239
240
  * `getUserMedia({ audio: true })` and stream it live into the
240
- * simulator's mock microphone. This is a manual override: iOS apps
241
+ * device's mock microphone. This is a manual override: apps
241
242
  * that actively consume microphone input also trigger capture
242
243
  * automatically, like camera demand. The component handles the
243
244
  * browser permission prompt, WebRTC track attachment, and
244
245
  * `microphoneStart`/`microphoneStop` signaling. Progress and failures
245
- * are reported via `onMicrophoneStateChange`. Ignored on Android.
246
+ * are reported via `onMicrophoneStateChange`. Ignored on Android
247
+ * servers without camera/mic injection support (Android 14 and older).
246
248
  */
247
249
  microphoneEnabled?: boolean;
248
250
 
@@ -1931,7 +1933,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1931
1933
  // handler firing on both `connected` and `completed`) must not
1932
1934
  // cancel an attach already in flight; only detach/teardown cancel.
1933
1935
  const attachMicrophone = (): Promise<void> => {
1934
- if (platform !== 'ios') return Promise.resolve();
1936
+ // No platform gate: attachMicrophoneOp bails when the session never
1937
+ // allocated a sendonly mic transceiver (non-capable servers).
1935
1938
  return runMicOp(() => attachMicrophoneOp());
1936
1939
  };
1937
1940
 
@@ -2479,8 +2482,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2479
2482
  updateStatus('WebSocket closed');
2480
2483
  };
2481
2484
 
2482
- // Request RTCConfiguration
2483
- const rtcConfigPromise = new Promise<RTCConfiguration>((resolve, reject) => {
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(message.rtcConfiguration);
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 = await rtcConfigPromise;
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
- // sim can pull frames later without renegotiating SDP.
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 simulator app actually opens its
2525
- // `AVCaptureSession` — the codec/SSRC negotiation happens
2526
- // once, here, and turning the camera on/off later is just a
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
- platform === 'ios' ?
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. iOS only,
2541
- // like the camera.
2555
+ // later is just `replaceTrack` — no SDP renegotiation.
2542
2556
  const outboundMicTransceiver =
2543
- platform === 'ios' ?
2557
+ canSendMedia ?
2544
2558
  peerConnection.addTransceiver('audio', {
2545
2559
  direction: 'sendonly',
2546
2560
  })
@@ -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 iOS sessions allocate the sendonly outbound-camera
2938
- // transceiver; without it there is nothing to feed the sim, so
2939
- // ignore camera requests entirely — no getUserMedia prompt and
2940
- // no onCameraDemandChange callbacks. Android intentionally stays
2941
- // silent, as the API docs promise.
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;
@@ -3171,7 +3184,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3171
3184
  break;
3172
3185
  }
3173
3186
  case 'microphoneRequest': {
3174
- if (platform !== 'ios' || !outboundMicSenderRef.current) {
3187
+ if (!outboundMicSenderRef.current) {
3175
3188
  break;
3176
3189
  }
3177
3190
  const active = message.active === true;