@limrun/ui 0.14.2 → 0.14.3

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.14.3",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "tag": "next"
@@ -237,23 +237,20 @@ interface RemoteControlProps {
237
237
  /**
238
238
  * iOS only: when true, capture the user's microphone via
239
239
  * `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.
240
+ * simulator's mock microphone. This is a manual override: iOS apps
241
+ * that actively consume microphone input also trigger capture
242
+ * automatically, like camera demand. The component handles the
243
+ * browser permission prompt, WebRTC track attachment, and
244
+ * `microphoneStart`/`microphoneStop` signaling. Progress and failures
245
+ * are reported via `onMicrophoneStateChange`. Ignored on Android.
248
246
  */
249
247
  microphoneEnabled?: boolean;
250
248
 
251
249
  /**
252
250
  * 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.
251
+ * after a manual or guest-demanded attempt resolves, when the host
252
+ * acknowledges or rejects the stream, when the browser revokes the
253
+ * track mid-stream, and when capture stops.
257
254
  */
258
255
  onMicrophoneStateChange?: (state: MicrophoneState) => void;
259
256
  }
@@ -642,14 +639,11 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
642
639
  packetsSent?: number;
643
640
  packetsLost?: number;
644
641
  } | 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.
642
+ // Outbound microphone state (iOS only). The manual prop and guest-driven
643
+ // microphoneRequest signals share one pre-allocated audio transceiver.
651
644
  const microphoneEnabledRef = useRef(microphoneEnabled);
652
645
  microphoneEnabledRef.current = microphoneEnabled;
646
+ const microphoneDemandActiveRef = useRef(false);
653
647
  const onMicrophoneStateChangeRef = useRef(onMicrophoneStateChange);
654
648
  onMicrophoneStateChangeRef.current = onMicrophoneStateChange;
655
649
  const outboundMicSenderRef = useRef<RTCRtpSender | null>(null);
@@ -1921,6 +1915,14 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1921
1915
  return next;
1922
1916
  };
1923
1917
 
1918
+ const isMicrophoneWanted = () => microphoneEnabledRef.current || microphoneDemandActiveRef.current;
1919
+
1920
+ const sendMicrophoneStart = (ws: WebSocket) => {
1921
+ const id = `ui-mic-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
1922
+ micStartPendingIdRef.current = id;
1923
+ ws.send(JSON.stringify({ type: 'microphoneStart', id }));
1924
+ };
1925
+
1924
1926
  // Capture the user's microphone and stream it to the simulator's
1925
1927
  // mock microphone. Safe to call redundantly: bails when already
1926
1928
  // attached, when the connection isn't up yet (the ICE-connected
@@ -1935,7 +1937,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1935
1937
 
1936
1938
  const attachMicrophoneOp = async () => {
1937
1939
  const generation = micGenerationRef.current;
1938
- const isCurrent = () => generation === micGenerationRef.current && microphoneEnabledRef.current;
1940
+ const isCurrent = () => generation === micGenerationRef.current && isMicrophoneWanted();
1939
1941
  const reportMicError = (error: string) => {
1940
1942
  safeInvoke('onMicrophoneStateChange', onMicrophoneStateChangeRef.current, {
1941
1943
  active: false,
@@ -2007,9 +2009,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2007
2009
  if (outboundMicStreamRef.current !== stream) return;
2008
2010
  void detachMicrophone('microphone capture ended by the browser');
2009
2011
  };
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 }));
2012
+ sendMicrophoneStart(ws);
2013
2013
  safeInvoke('onMicrophoneStateChange', onMicrophoneStateChangeRef.current, {
2014
2014
  active: true,
2015
2015
  label: audioTrack.label || undefined,
@@ -2203,10 +2203,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2203
2203
  stopCameraStatsPoller();
2204
2204
  stopOutboundLocalStream();
2205
2205
  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.
2206
+ // Same for the live microphone. Manual enablement survives reconnect;
2207
+ // guest demand is replayed by the host after it assigns the new session.
2209
2208
  ++micGenerationRef.current;
2209
+ microphoneDemandActiveRef.current = false;
2210
2210
  micStartPendingIdRef.current = null;
2211
2211
  if (outboundMicStreamRef.current) {
2212
2212
  stopMediaStream(outboundMicStreamRef.current);
@@ -2770,7 +2770,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2770
2770
  clearIceDisconnectedGrace();
2771
2771
  // Re-attach the live microphone across reconnects (or apply a
2772
2772
  // prop that was flipped on before the connection was up).
2773
- if (microphoneEnabledRef.current && !outboundMicStreamRef.current) {
2773
+ if (isMicrophoneWanted() && !outboundMicStreamRef.current) {
2774
2774
  void attachMicrophone();
2775
2775
  }
2776
2776
  return;
@@ -3170,6 +3170,29 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3170
3170
  startCameraStatsPoller();
3171
3171
  break;
3172
3172
  }
3173
+ case 'microphoneRequest': {
3174
+ if (platform !== 'ios' || !outboundMicSenderRef.current) {
3175
+ break;
3176
+ }
3177
+ const active = message.active === true;
3178
+ microphoneDemandActiveRef.current = active;
3179
+ // eslint-disable-next-line no-console
3180
+ console.info('[RemoteControl] microphoneRequest received, active=', active);
3181
+ if (!active) {
3182
+ if (!microphoneEnabledRef.current) {
3183
+ await detachMicrophone();
3184
+ }
3185
+ break;
3186
+ }
3187
+ if (outboundMicStreamRef.current) {
3188
+ // A manual stream may already be attached. Re-select it on the
3189
+ // host because file playback can have replaced its source.
3190
+ sendMicrophoneStart(ws);
3191
+ break;
3192
+ }
3193
+ await attachMicrophone();
3194
+ break;
3195
+ }
3173
3196
  case 'microphoneStartResult': {
3174
3197
  if (micStartPendingIdRef.current === null || message.id !== micStartPendingIdRef.current) {
3175
3198
  break;
@@ -3312,11 +3335,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
3312
3335
  }
3313
3336
  }, [cameraAspect]);
3314
3337
 
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.
3338
+ // Drive the manual override without interrupting guest-driven demand.
3318
3339
  useEffect(() => {
3319
- if (microphoneEnabled) {
3340
+ if (isMicrophoneWanted()) {
3320
3341
  void attachMicrophone();
3321
3342
  } else {
3322
3343
  void detachMicrophone();