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.
|
|
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
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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
|
|
622
|
+
guard placeholderPixelBuffer != nil else {
|
|
623
|
+
print("🖼️ [PiP] No placeholder buffer available")
|
|
540
624
|
completion(false)
|
|
541
625
|
return
|
|
542
626
|
}
|
|
543
627
|
|
|
544
|
-
//
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
//
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|