@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limrun/ui",
3
- "version": "0.14.2",
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,25 +236,23 @@ 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. Unlike the camera (which is
241
- * demand-driven by apps inside the sim), the microphone is
242
- * user-driven: flip this prop from a button in your UI. The
243
- * component handles the browser permission prompt, the WebRTC
244
- * track attach (no SDP renegotiation — the audio slot is
245
- * pre-allocated) and the `microphoneStart`/`microphoneStop`
246
- * signaling to the host. Progress and failures are reported via
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 an enable attempt resolves (granted or denied), when the
254
- * host acknowledges or rejects the stream, when the browser
255
- * revokes the track mid-stream, and on disable. Use it to render
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
- // User-driven outbound microphone state (iOS only). Unlike the
646
- // camera, the mic is toggled by the `microphoneEnabled` prop; the
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
- 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).
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 && microphoneEnabledRef.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
- const id = `ui-mic-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
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. No `microphoneStop` signal here —
2207
- // the WS is going away with the connection; if `microphoneEnabled`
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
- 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
  })
@@ -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 (microphoneEnabledRef.current && !outboundMicStreamRef.current) {
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 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;
@@ -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 live microphone from the `microphoneEnabled` prop.
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 (microphoneEnabled) {
3353
+ if (isMicrophoneWanted()) {
3320
3354
  void attachMicrophone();
3321
3355
  } else {
3322
3356
  void detachMicrophone();