@limrun/ui 0.9.0-rc.11 → 0.9.0-rc.13

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.9.0-rc.11",
3
+ "version": "0.9.0-rc.13",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -140,6 +140,35 @@ interface RemoteControlProps {
140
140
  * @default 2000
141
141
  */
142
142
  axMaxBackoffMs?: number;
143
+
144
+ /**
145
+ * Optional outbound camera input.
146
+ *
147
+ * When provided, every video track on this `MediaStream` is forwarded
148
+ * up the same `RTCPeerConnection` that already carries the device
149
+ * screen downstream. On iOS instances, the limulator-side injector
150
+ * splices those frames into apps that use AVFoundation, so the user's
151
+ * browser camera becomes the iOS camera (`AVCaptureDevice.default(for:
152
+ * .video)`).
153
+ *
154
+ * Today this is only honored by iOS instances launched with the
155
+ * camera runtime; Android instances ignore it.
156
+ *
157
+ * Lifecycle expectations:
158
+ * - Pass `null`/`undefined` to skip outbound camera (default).
159
+ * - Pass a `MediaStream` obtained from
160
+ * `navigator.mediaDevices.getUserMedia({ video: true })` (or any
161
+ * other source — screen capture, virtual camera, etc.).
162
+ * - Changing the `MediaStream.id` triggers a connection restart so
163
+ * the new track is included in the SDP offer. The reference itself
164
+ * is allowed to be unstable across renders — we key the reconnect
165
+ * on `id`, not object identity, so memoization isn't required.
166
+ * - Stopping the tracks (e.g. `track.stop()`) on the parent side is
167
+ * sufficient to cut the outbound feed without restarting; the peer
168
+ * connection just stops getting frames on that track. To fully
169
+ * detach the track from the SDP, swap the prop to `null`.
170
+ */
171
+ cameraStream?: MediaStream | null;
143
172
  }
144
173
 
145
174
  interface ScreenshotData {
@@ -338,6 +367,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
338
367
  onAxStatusChange,
339
368
  axPollIntervalMs,
340
369
  axMaxBackoffMs,
370
+ cameraStream,
341
371
  }: RemoteControlProps,
342
372
  ref,
343
373
  ) => {
@@ -363,6 +393,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
363
393
  // Mirrored to a ref so stale closures in event handlers see the latest value.
364
394
  const autoReconnectRef = useRef(autoReconnect);
365
395
  autoReconnectRef.current = autoReconnect;
396
+ // Outbound camera input. Held in a ref so the deferred WebRTC setup
397
+ // path picks up the latest stream even if React re-renders between
398
+ // the connection effect firing and `startAttempt` reaching the
399
+ // `addTrack` step. Reconnects are keyed on `MediaStream.id`, not
400
+ // reference identity (see the connection effect's deps).
401
+ const cameraStreamRef = useRef<MediaStream | null>(cameraStream ?? null);
402
+ cameraStreamRef.current = cameraStream ?? null;
366
403
  const firstFrameShownRef = useRef(false);
367
404
  const pendingScreenshotResolversRef = useRef<
368
405
  Map<string, (value: ScreenshotData | PromiseLike<ScreenshotData>) => void>
@@ -1644,6 +1681,27 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1644
1681
  peerConnection.addTransceiver('audio', { direction: 'recvonly' });
1645
1682
  const videoTransceiver = peerConnection.addTransceiver('video', { direction: 'recvonly' });
1646
1683
 
1684
+ // Outbound camera input — splice the parent-provided MediaStream's
1685
+ // video tracks into the same PC. `addTrack` creates an implicit
1686
+ // sendonly transceiver and lands the track in the SDP offer; the
1687
+ // limulator side picks it up via `peerConnection(_:didAdd:)` and
1688
+ // forwards every frame into the camera-injector IOSurface ring.
1689
+ // We pass the original MediaStream so the answerer can recover
1690
+ // the parent stream id if it cares; today nothing does, but it
1691
+ // costs nothing to be correct.
1692
+ const outboundCamera = cameraStreamRef.current;
1693
+ if (outboundCamera) {
1694
+ for (const track of outboundCamera.getVideoTracks()) {
1695
+ peerConnection.addTrack(track, outboundCamera);
1696
+ }
1697
+ debugLog(
1698
+ 'Attached outbound camera tracks:',
1699
+ outboundCamera.getVideoTracks().length,
1700
+ 'streamId:',
1701
+ outboundCamera.id,
1702
+ );
1703
+ }
1704
+
1647
1705
  // As hardware encoder, we use H265 for iOS and VP9 for Android.
1648
1706
  // We make sure these two are the first ones in the list.
1649
1707
  // If not, the fallback is H264 which is also hardware accelerated, although not as good,
@@ -2080,7 +2138,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2080
2138
  stop();
2081
2139
  document.removeEventListener('visibilitychange', handleVisibilityChange);
2082
2140
  };
2083
- }, [url, token, propSessionId]);
2141
+ // We key reconnects on `MediaStream.id` rather than the prop's
2142
+ // object identity so callers don't have to memoize the stream —
2143
+ // turning the camera on/off swaps the underlying MediaStream and
2144
+ // its id, but rerenders that happen to re-create the parent's
2145
+ // wrapping object don't trigger churn.
2146
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2147
+ }, [url, token, propSessionId, cameraStream?.id]);
2084
2148
 
2085
2149
  // Recompute the inspect-overlay geometry (container-local pixel rect of
2086
2150
  // the actually-rendered video content) from the current mapping context.