expo-realtime-ivs-broadcast 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -8,7 +8,8 @@ This module provides React Native components and a comprehensive API to integrat
8
8
 
9
9
  | Library Version | Expo SDK | React Native | React | Notes |
10
10
  |-----------------|----------|--------------|---------|-------|
11
- | 0.2.0 | 54 | 0.81.x | 19.1.x | **Added Picture-in-Picture support** |
11
+ | 0.2.1 | 54 | 0.81.x | 19.1.x | **Fixed iOS PiP pre-warming reliability** |
12
+ | 0.2.0 | 54 | 0.81.x | 19.1.x | Added Picture-in-Picture support |
12
13
  | 0.1.7 | 54 | 0.81.x | 19.1.x | |
13
14
  | 0.1.4 | 53 | 0.79.x | 19.0.x | |
14
15
 
@@ -224,10 +224,16 @@ class ExpoIVSStagePreviewView: ExpoView {
224
224
  }
225
225
 
226
226
  /// Register this view with the stage manager for PiP (Picture-in-Picture) support
227
+ /// Only registers when the view is actually in a window hierarchy
227
228
  private func registerForPiP() {
228
- // Use self as the source view since this is the container that shows the video
229
- stageManager?.registerLocalPreviewView(self)
230
- print("ExpoIVSStagePreviewView: Registered for PiP support")
229
+ // Only register if we're in a window - PiP requires a visible source view
230
+ if window != nil {
231
+ stageManager?.registerLocalPreviewView(self)
232
+ print("ExpoIVSStagePreviewView: Registered for PiP support (view is in window)")
233
+ } else {
234
+ print("ExpoIVSStagePreviewView: Deferring PiP registration until view is in window")
235
+ // Will be registered in didMoveToWindow
236
+ }
231
237
  }
232
238
 
233
239
  override func layoutSubviews() {
@@ -244,12 +250,22 @@ class ExpoIVSStagePreviewView: ExpoView {
244
250
 
245
251
  override func didMoveToWindow() {
246
252
  super.didMoveToWindow()
247
- // When the view is added to a window, try to attach stream if not already done
248
- if window != nil && customPreviewLayer == nil && ivsImagePreviewView == nil {
249
- print("ExpoIVSStagePreviewView: didMoveToWindow - attempting to attach stream")
250
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
251
- self?.attachStream()
253
+ if window != nil {
254
+ // When the view is added to a window, try to attach stream if not already done
255
+ if customPreviewLayer == nil && ivsImagePreviewView == nil {
256
+ print("ExpoIVSStagePreviewView: didMoveToWindow - attempting to attach stream")
257
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
258
+ self?.attachStream()
259
+ }
260
+ } else {
261
+ // Stream already attached, but now we can register for PiP since we're in a window
262
+ print("ExpoIVSStagePreviewView: didMoveToWindow - registering for PiP now that view is in window")
263
+ stageManager?.registerLocalPreviewView(self)
252
264
  }
265
+ } else {
266
+ // View removed from window - unregister from PiP
267
+ print("ExpoIVSStagePreviewView: didMoveToWindow - view removed from window, unregistering PiP")
268
+ stageManager?.registerLocalPreviewView(nil)
253
269
  }
254
270
  }
255
271
  }
@@ -302,6 +302,9 @@ public class IVSPictureInPictureController: NSObject {
302
302
  private var viewFrameCapture: ViewFrameCapture?
303
303
  private weak var captureTargetView: UIView?
304
304
 
305
+ // Pending source view (when setup is deferred because view is not in window)
306
+ private weak var pendingSourceView: UIView?
307
+
305
308
  // Placeholder frame generator for broadcaster PiP (when camera is unavailable in background)
306
309
  private var placeholderTimer: Timer?
307
310
  private var placeholderPixelBuffer: CVPixelBuffer?
@@ -441,6 +444,57 @@ public class IVSPictureInPictureController: NSObject {
441
444
  return
442
445
  }
443
446
 
447
+ // IMPORTANT: Check if the source view is in a window hierarchy
448
+ // If not, defer setup until it is
449
+ if sourceView.window == nil {
450
+ print("🖼️ [PiP] Warning: Source view not in window hierarchy yet. Deferring setup...")
451
+ self.pendingSourceView = sourceView
452
+
453
+ // Observe when the view gets added to window
454
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak sourceView] in
455
+ guard let self = self, let view = sourceView else { return }
456
+ if view.window != nil {
457
+ print("🖼️ [PiP] Source view now in window, completing setup")
458
+ self.pendingSourceView = nil
459
+ self.completeSetupWithSourceView(view, pipVideoCallVC: pipVideoCallVC)
460
+ } else {
461
+ // Retry with increasing delays
462
+ self.retrySetupWithSourceView(view, pipVideoCallVC: pipVideoCallVC, attempt: 1)
463
+ }
464
+ }
465
+ return
466
+ }
467
+
468
+ completeSetupWithSourceView(sourceView, pipVideoCallVC: pipVideoCallVC)
469
+ }
470
+
471
+ /// Retry setup with exponential backoff
472
+ private func retrySetupWithSourceView(_ sourceView: UIView, pipVideoCallVC: AVPictureInPictureVideoCallViewController, attempt: Int) {
473
+ let maxAttempts = 10
474
+ let delay = min(0.1 * pow(1.5, Double(attempt)), 2.0) // Max 2 second delay
475
+
476
+ guard attempt < maxAttempts else {
477
+ print("🖼️ [PiP] Warning: Source view still not in window after \(maxAttempts) attempts. Setting up anyway...")
478
+ completeSetupWithSourceView(sourceView, pipVideoCallVC: pipVideoCallVC)
479
+ return
480
+ }
481
+
482
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self, weak sourceView] in
483
+ guard let self = self, let view = sourceView else { return }
484
+
485
+ if view.window != nil {
486
+ print("🖼️ [PiP] Source view now in window (attempt \(attempt + 1)), completing setup")
487
+ self.pendingSourceView = nil
488
+ self.completeSetupWithSourceView(view, pipVideoCallVC: pipVideoCallVC)
489
+ } else {
490
+ print("🖼️ [PiP] Source view still not in window (attempt \(attempt + 1)), retrying...")
491
+ self.retrySetupWithSourceView(view, pipVideoCallVC: pipVideoCallVC, attempt: attempt + 1)
492
+ }
493
+ }
494
+ }
495
+
496
+ /// Actually complete the PiP setup once we have a valid source view
497
+ private func completeSetupWithSourceView(_ sourceView: UIView, pipVideoCallVC: AVPictureInPictureVideoCallViewController) {
444
498
  self.activeSourceView = sourceView
445
499
  self.pipContainerView = sourceView
446
500
 
@@ -459,15 +513,43 @@ public class IVSPictureInPictureController: NSObject {
459
513
 
460
514
  self.pipController = controller
461
515
 
462
- // Pre-warm with placeholder frames so PiP is possible immediately
463
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
464
- for _ in 0..<3 {
465
- self?.enqueuePlaceholderFrame()
466
- }
516
+ // Pre-warm with more placeholder frames and longer delay to ensure PiP is possible
517
+ print("🖼️ [PiP] Pre-warming display layer with placeholder frames...")
518
+ aggressivePreWarm { [weak self, weak controller] in
519
+ guard let self = self, let controller = controller else { return }
520
+ print("🖼️ [PiP] Setup complete with source view: \(sourceView)")
521
+ print("🖼️ [PiP] Source view in window: \(sourceView.window != nil)")
522
+ print("🖼️ [PiP] isPictureInPicturePossible: \(controller.isPictureInPicturePossible)")
523
+ }
524
+ }
525
+
526
+ /// Aggressive pre-warming to ensure display layer is ready
527
+ private func aggressivePreWarm(completion: @escaping () -> Void) {
528
+ // Enqueue initial batch of frames immediately
529
+ for _ in 0..<5 {
530
+ enqueuePlaceholderFrame()
467
531
  }
468
532
 
469
- print("🖼️ [PiP] Setup complete with source view: \(sourceView)")
470
- print("🖼️ [PiP] isPictureInPicturePossible: \(controller.isPictureInPicturePossible)")
533
+ // Then enqueue more frames over time to ensure the layer is warm
534
+ var framesEnqueued = 5
535
+ let timer = Timer.scheduledTimer(withTimeInterval: 0.033, repeats: true) { [weak self] timer in
536
+ guard let self = self else {
537
+ timer.invalidate()
538
+ return
539
+ }
540
+
541
+ self.enqueuePlaceholderFrame()
542
+ framesEnqueued += 1
543
+
544
+ // After 15 frames (~0.5s), check if PiP is possible
545
+ if framesEnqueued >= 15 {
546
+ timer.invalidate()
547
+ DispatchQueue.main.async {
548
+ completion()
549
+ }
550
+ }
551
+ }
552
+ RunLoop.main.add(timer, forMode: .common)
471
553
  }
472
554
 
473
555
  /// Disable PiP
@@ -535,23 +617,67 @@ public class IVSPictureInPictureController: NSObject {
535
617
  }
536
618
 
537
619
  /// Pre-warm the display layer with placeholder frames to make PiP possible
620
+ /// Uses aggressive pre-warming with retry logic
538
621
  private func preWarmWithPlaceholder(completion: @escaping (Bool) -> Void) {
539
- guard let pixelBuffer = placeholderPixelBuffer else {
622
+ guard placeholderPixelBuffer != nil else {
623
+ print("🖼️ [PiP] No placeholder buffer available")
540
624
  completion(false)
541
625
  return
542
626
  }
543
627
 
544
- // Enqueue several placeholder frames to prime the display layer
545
- for _ in 0..<5 {
546
- enqueuePlaceholderFrame()
628
+ // Check if source view is in window - if not, that's likely the issue
629
+ if let sourceView = activeSourceView {
630
+ print("🖼️ [PiP] Pre-warm: Source view in window: \(sourceView.window != nil)")
631
+ if sourceView.window == nil {
632
+ print("🖼️ [PiP] Warning: Source view not in window during pre-warm!")
633
+ }
547
634
  }
548
635
 
549
- // Wait a moment for frames to be processed, then check if PiP is possible
550
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
551
- let isPossible = self?.pipController?.isPictureInPicturePossible ?? false
552
- print("🖼️ [PiP] Pre-warm complete, isPictureInPicturePossible: \(isPossible)")
553
- completion(isPossible)
636
+ // Use aggressive pre-warming with polling
637
+ var attemptCount = 0
638
+ let maxAttempts = 5
639
+
640
+ func tryPreWarm() {
641
+ attemptCount += 1
642
+
643
+ // Enqueue a batch of frames
644
+ for _ in 0..<10 {
645
+ enqueuePlaceholderFrame()
646
+ }
647
+
648
+ // Use increasing delays for each attempt
649
+ let delay = 0.15 * Double(attemptCount)
650
+
651
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
652
+ guard let self = self else {
653
+ completion(false)
654
+ return
655
+ }
656
+
657
+ let isPossible = self.pipController?.isPictureInPicturePossible ?? false
658
+ print("🖼️ [PiP] Pre-warm attempt \(attemptCount)/\(maxAttempts), isPictureInPicturePossible: \(isPossible)")
659
+
660
+ if isPossible {
661
+ completion(true)
662
+ } else if attemptCount < maxAttempts {
663
+ // Try again with more frames
664
+ tryPreWarm()
665
+ } else {
666
+ // Final attempt - log diagnostic info
667
+ print("🖼️ [PiP] Pre-warm failed after \(maxAttempts) attempts")
668
+ if let sourceView = self.activeSourceView {
669
+ print("🖼️ [PiP] - Source view in window: \(sourceView.window != nil)")
670
+ print("🖼️ [PiP] - Source view frame: \(sourceView.frame)")
671
+ print("🖼️ [PiP] - Source view hidden: \(sourceView.isHidden)")
672
+ }
673
+ print("🖼️ [PiP] - Display layer status: \(self.sampleBufferDisplayLayer?.status.rawValue ?? -1)")
674
+ print("🖼️ [PiP] - Frames enqueued: \(self.enqueuedFrameCount)")
675
+ completion(false)
676
+ }
677
+ }
554
678
  }
679
+
680
+ tryPreWarm()
555
681
  }
556
682
 
557
683
  /// Stop PiP manually
@@ -1060,10 +1060,21 @@ class IVSStageManager: NSObject, IVSStageStreamDelegate, IVSStageStrategy, IVSSt
1060
1060
  } catch {
1061
1061
  print("🖼️ [PiP] Warning: Could not get preview view from device: \(error)")
1062
1062
  // Try to find a remote view that's rendering this device
1063
- if let remoteView = remoteViews.compactMap({ $0.value }).first(where: { $0.currentRenderedDeviceUrn == device.descriptor().urn }),
1064
- let previewViewForPiP = remoteView.previewViewForPiP {
1063
+ if let remoteView = remoteViews.compactMap({ $0.value }).first(where: { $0.currentRenderedDeviceUrn == device.descriptor().urn }) {
1064
+ // Always set up with the remote view container - it's the visible video view
1065
1065
  pipController.setupWithSourceView(remoteView as UIView)
1066
- pipTargetView = previewViewForPiP
1066
+ // Use the inner preview view if available, otherwise use the container
1067
+ pipTargetView = remoteView.previewViewForPiP ?? remoteView
1068
+ print("🖼️ [PiP] Set up with remote view container")
1069
+ } else {
1070
+ // Last resort: try to find ANY registered remote view that's rendering video
1071
+ if let anyRemoteView = remoteViews.compactMap({ $0.value }).first(where: { $0.isRenderingVideo }) {
1072
+ pipController.setupWithSourceView(anyRemoteView as UIView)
1073
+ pipTargetView = anyRemoteView.previewViewForPiP ?? anyRemoteView
1074
+ print("🖼️ [PiP] Set up with fallback remote view")
1075
+ } else {
1076
+ print("🖼️ [PiP] ERROR: No source view found for PiP - controller will not be initialized!")
1077
+ }
1067
1078
  }
1068
1079
  }
1069
1080
  }
@@ -1117,6 +1128,12 @@ class IVSStageManager: NSObject, IVSStageStreamDelegate, IVSStageStrategy, IVSSt
1117
1128
  // Cast to UIView explicitly since ExpoIVSRemoteStreamView extends ExpoView -> UIView
1118
1129
  candidateSourceView = remoteView as UIView
1119
1130
  print("🖼️ [PiP] Found matching remote view for source")
1131
+ } else {
1132
+ // Fallback: use any remote view that's rendering video
1133
+ if let anyRenderingView = remoteViews.compactMap({ $0.value }).first(where: { $0.isRenderingVideo }) {
1134
+ candidateSourceView = anyRenderingView as UIView
1135
+ print("🖼️ [PiP] Using fallback remote view for source (URN mismatch)")
1136
+ }
1120
1137
  }
1121
1138
 
1122
1139
  attachToDevice(imageDevice, sourceView: candidateSourceView)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-realtime-ivs-broadcast",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "An Expo module for real-time broadcasting using Amazon IVS.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",