@stream-io/video-react-native-sdk 1.37.1-beta.0 → 1.38.1

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.
Files changed (84) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt +0 -81
  3. package/dist/commonjs/hooks/index.js +0 -11
  4. package/dist/commonjs/hooks/index.js.map +1 -1
  5. package/dist/commonjs/modules/call-manager/CallManager.js +13 -0
  6. package/dist/commonjs/modules/call-manager/CallManager.js.map +1 -1
  7. package/dist/commonjs/providers/StreamCall/AudioInterruptionTracer.js +37 -0
  8. package/dist/commonjs/providers/StreamCall/AudioInterruptionTracer.js.map +1 -0
  9. package/dist/commonjs/providers/StreamCall/index.js +2 -1
  10. package/dist/commonjs/providers/StreamCall/index.js.map +1 -1
  11. package/dist/commonjs/utils/internal/callingx/callingx.js +2 -2
  12. package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
  13. package/dist/commonjs/utils/internal/registerSDKGlobals.js +16 -0
  14. package/dist/commonjs/utils/internal/registerSDKGlobals.js.map +1 -1
  15. package/dist/commonjs/utils/push/internal/ios.js +5 -0
  16. package/dist/commonjs/utils/push/internal/ios.js.map +1 -1
  17. package/dist/commonjs/version.js +1 -1
  18. package/dist/commonjs/version.js.map +1 -1
  19. package/dist/module/hooks/index.js +0 -1
  20. package/dist/module/hooks/index.js.map +1 -1
  21. package/dist/module/modules/call-manager/CallManager.js +13 -0
  22. package/dist/module/modules/call-manager/CallManager.js.map +1 -1
  23. package/dist/module/providers/StreamCall/AudioInterruptionTracer.js +30 -0
  24. package/dist/module/providers/StreamCall/AudioInterruptionTracer.js.map +1 -0
  25. package/dist/module/providers/StreamCall/index.js +2 -1
  26. package/dist/module/providers/StreamCall/index.js.map +1 -1
  27. package/dist/module/utils/internal/callingx/callingx.js +2 -2
  28. package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
  29. package/dist/module/utils/internal/registerSDKGlobals.js +17 -1
  30. package/dist/module/utils/internal/registerSDKGlobals.js.map +1 -1
  31. package/dist/module/utils/push/internal/ios.js +5 -0
  32. package/dist/module/utils/push/internal/ios.js.map +1 -1
  33. package/dist/module/version.js +1 -1
  34. package/dist/module/version.js.map +1 -1
  35. package/dist/typescript/hooks/index.d.ts +0 -1
  36. package/dist/typescript/hooks/index.d.ts.map +1 -1
  37. package/dist/typescript/modules/call-manager/CallManager.d.ts +8 -1
  38. package/dist/typescript/modules/call-manager/CallManager.d.ts.map +1 -1
  39. package/dist/typescript/modules/call-manager/types.d.ts +6 -0
  40. package/dist/typescript/modules/call-manager/types.d.ts.map +1 -1
  41. package/dist/typescript/providers/StreamCall/AudioInterruptionTracer.d.ts +5 -0
  42. package/dist/typescript/providers/StreamCall/AudioInterruptionTracer.d.ts.map +1 -0
  43. package/dist/typescript/providers/StreamCall/index.d.ts.map +1 -1
  44. package/dist/typescript/utils/internal/registerSDKGlobals.d.ts.map +1 -1
  45. package/dist/typescript/utils/push/internal/ios.d.ts.map +1 -1
  46. package/dist/typescript/version.d.ts +1 -1
  47. package/dist/typescript/version.d.ts.map +1 -1
  48. package/ios/PictureInPicture/PictureInPictureAvatarView.swift +3 -1
  49. package/ios/PictureInPicture/StreamBufferTransformer.swift +13 -4
  50. package/ios/PictureInPicture/StreamPictureInPictureVideoRenderer.swift +79 -71
  51. package/ios/PictureInPicture/StreamRTCYUVBuffer.swift +20 -16
  52. package/ios/StreamInCallManager.swift +256 -81
  53. package/ios/StreamVideoReactNative-Bridging-Header.h +0 -2
  54. package/ios/StreamVideoReactNative.m +0 -81
  55. package/package.json +11 -11
  56. package/src/hooks/index.ts +0 -1
  57. package/src/modules/call-manager/CallManager.ts +25 -1
  58. package/src/modules/call-manager/types.ts +7 -0
  59. package/src/providers/StreamCall/AudioInterruptionTracer.tsx +51 -0
  60. package/src/providers/StreamCall/index.tsx +2 -0
  61. package/src/utils/internal/callingx/callingx.ts +2 -2
  62. package/src/utils/internal/registerSDKGlobals.ts +23 -1
  63. package/src/utils/push/internal/ios.ts +5 -0
  64. package/src/version.ts +1 -1
  65. package/android/src/main/java/com/streamvideo/reactnative/recorder/AudioPipeline.kt +0 -436
  66. package/android/src/main/java/com/streamvideo/reactnative/recorder/EncoderConstants.kt +0 -17
  67. package/android/src/main/java/com/streamvideo/reactnative/recorder/PipelineHost.kt +0 -36
  68. package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderPlaybackSamplesSink.kt +0 -60
  69. package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderVideoSink.kt +0 -31
  70. package/android/src/main/java/com/streamvideo/reactnative/recorder/TracksRecorderManager.kt +0 -329
  71. package/android/src/main/java/com/streamvideo/reactnative/recorder/VideoPipeline.kt +0 -472
  72. package/dist/commonjs/hooks/useLoopbackRecording.js +0 -243
  73. package/dist/commonjs/hooks/useLoopbackRecording.js.map +0 -1
  74. package/dist/module/hooks/useLoopbackRecording.js +0 -238
  75. package/dist/module/hooks/useLoopbackRecording.js.map +0 -1
  76. package/dist/typescript/hooks/useLoopbackRecording.d.ts +0 -85
  77. package/dist/typescript/hooks/useLoopbackRecording.d.ts.map +0 -1
  78. package/ios/TracksRecorder/AudioPipeline.swift +0 -270
  79. package/ios/TracksRecorder/PipelineHost.swift +0 -56
  80. package/ios/TracksRecorder/RecorderAudioRenderTap.swift +0 -154
  81. package/ios/TracksRecorder/RecorderVideoSink.swift +0 -137
  82. package/ios/TracksRecorder/TracksRecorderManager.swift +0 -327
  83. package/ios/TracksRecorder/VideoPipeline.swift +0 -297
  84. package/src/hooks/useLoopbackRecording.ts +0 -438
@@ -2,6 +2,7 @@ import Foundation
2
2
  import React
3
3
  import UIKit
4
4
  import AVFoundation
5
+ import Combine
5
6
  import stream_react_native_webrtc
6
7
  import AVKit
7
8
  import MediaPlayer
@@ -29,6 +30,10 @@ private enum Constants {
29
30
  static let stereoRefreshDebounceSeconds: TimeInterval = 0.5
30
31
  }
31
32
 
33
+ private enum StreamInCallManagerEvents {
34
+ static let audioInterruption = "StreamInCallManagerAudioInterruption"
35
+ }
36
+
32
37
  @objc(StreamInCallManager)
33
38
  class StreamInCallManager: RCTEventEmitter {
34
39
 
@@ -47,7 +52,11 @@ class StreamInCallManager: RCTEventEmitter {
47
52
  }
48
53
 
49
54
  private var hasRegisteredRouteObserver = false
55
+ private var hasRegisteredInterruptionObserver = false
50
56
  private var stereoRefreshWorkItem: DispatchWorkItem?
57
+ /// Combine subscription to the AudioDeviceModule's engine-lifecycle publisher.
58
+ /// Wired in `setup()`; torn down in `stop()`.
59
+ private var engineSubscription: AnyCancellable?
51
60
 
52
61
  override func invalidate() {
53
62
  stop()
@@ -91,69 +100,168 @@ class StreamInCallManager: RCTEventEmitter {
91
100
  }
92
101
  }
93
102
 
103
+ /// Builds the audio config for the current role/device and sets it as WebRTC's default.
104
+ private func makeAudioConfiguration() -> RTCAudioSessionConfiguration {
105
+ let category: AVAudioSession.Category
106
+ let mode: AVAudioSession.Mode
107
+ let options: AVAudioSession.CategoryOptions
108
+
109
+ if callAudioRole == .listener {
110
+ // High quality audio playback, microphone disabled.
111
+ // .spokenAudio + .mixWithOthers: listener flow is passive spoken-audio
112
+ // playback, so let other apps' audio coexist (music keeps playing under it)
113
+ // and let the system handle ducking semantics for spoken content.
114
+ category = .playback
115
+ mode = .spokenAudio
116
+ options = [.mixWithOthers]
117
+ // TODO: for stereo we should disallow BluetoothHFP and allow only allowBluetoothA2DP
118
+ // note: this is the behaviour of iOS native SDK, but fails here with (OSStatus error -50.)
119
+ // options = self.enableStereo ? [.allowBluetoothA2DP] : []
120
+ } else if !micPermissionGranted() {
121
+ // Communicator without mic permission (denied or not yet asked): use a
122
+ // pure-output, no-VPIO session so the output-only AVAudioEngine renders
123
+ // remote audio. Mirrors stream-video-swift
124
+ // (isRecordingEnabled ? .playAndRecord : .playback). We can't record anyway.
125
+ // Self-heals: granting permission + unmuting rebuilds the engine.
126
+ // Known gap: .playback can't route to the receiver (earpiece) — that route
127
+ // only exists under .playAndRecord.
128
+ category = .playback
129
+ mode = .spokenAudio
130
+ options = []
131
+ } else {
132
+ // XCode 16 and older don't expose .allowBluetoothHFP
133
+ // https://forums.swift.org/t/xcode-26-avaudiosession-categoryoptions-allowbluetooth-deprecated/80956
134
+ #if compiler(>=6.2) // For Xcode 26.0+
135
+ let bluetoothOption: AVAudioSession.CategoryOptions = .allowBluetoothHFP
136
+ #else
137
+ let bluetoothOption: AVAudioSession.CategoryOptions = .allowBluetooth
138
+ #endif
139
+ category = .playAndRecord
140
+ mode = .voiceChat
141
+ options = defaultAudioDevice == .speaker ? [bluetoothOption, .defaultToSpeaker] : [bluetoothOption]
142
+ }
143
+
144
+ let rtcConfig = RTCAudioSessionConfiguration.webRTC()
145
+ rtcConfig.category = category.rawValue
146
+ rtcConfig.mode = mode.rawValue
147
+ rtcConfig.categoryOptions = options
148
+ // Keep WebRTC's internal state consistent during interruptions/route changes.
149
+ RTCAudioSessionConfiguration.setWebRTC(rtcConfig)
150
+ return rtcConfig
151
+ }
152
+
153
+ private func micPermissionGranted() -> Bool {
154
+ if #available(iOS 17.0, *) {
155
+ return AVAudioApplication.shared.recordPermission == .granted
156
+ } else {
157
+ return AVAudioSession.sharedInstance().recordPermission == .granted
158
+ }
159
+ }
160
+
94
161
  @objc
95
162
  func setup() {
96
163
  audioSessionQueue.async { [self] in
97
- let intendedCategory: AVAudioSession.Category!
98
- let intendedMode: AVAudioSession.Mode!
99
- let intendedOptions: AVAudioSession.CategoryOptions!
100
-
101
164
  let adm = getAudioDeviceModule()
102
- let wasRecording = adm.isRecording
103
- let wasPlaying = adm.isPlaying
104
- adm.reset()
105
165
 
106
- if (callAudioRole == .listener) {
107
- // enables high quality audio playback but disables microphone
108
- intendedCategory = .playback
109
- intendedMode = .default
110
- intendedOptions = []
111
- // TODO: for stereo we should disallow BluetoothHFP and allow only allowBluetoothA2DP
112
- // note: this is the behaviour of iOS native SDK, but fails here with (OSStatus error -50.)
113
- // intendedOptions = self.enableStereo ? [.allowBluetoothA2DP] : []
114
- if (self.enableStereo) {
115
- adm.setStereoPlayoutPreference(true)
116
- }
117
- } else {
118
- intendedCategory = .playAndRecord
119
- intendedMode = .voiceChat
120
-
121
- // XCode 16 and older don't expose .allowBluetoothHFP
122
- // https://forums.swift.org/t/xcode-26-avaudiosession-categoryoptions-allowbluetooth-deprecated/80956
123
- #if compiler(>=6.2) // For Xcode 26.0+
124
- let bluetoothOption: AVAudioSession.CategoryOptions = .allowBluetoothHFP
125
- #else
126
- let bluetoothOption: AVAudioSession.CategoryOptions = .allowBluetooth
127
- #endif
128
- intendedOptions = defaultAudioDevice == .speaker ? [bluetoothOption, .defaultToSpeaker] : [bluetoothOption]
166
+ // Stereo is listener-only and applies live. Cleared by stop()'s reset().
167
+ if callAudioRole == .listener && enableStereo {
168
+ adm.setStereoPlayoutPreference(true)
129
169
  }
130
- log("Setup with category: \(intendedCategory.rawValue), mode: \(intendedMode.rawValue), options: \(String(describing: intendedOptions))")
131
- let rtcConfig = RTCAudioSessionConfiguration.webRTC()
132
- rtcConfig.category = intendedCategory.rawValue
133
- rtcConfig.mode = intendedMode.rawValue
134
- rtcConfig.categoryOptions = intendedOptions
135
- // This ensures WebRTC's internal state stays consistent during interruptions/route changes
136
- RTCAudioSessionConfiguration.setWebRTC(rtcConfig)
137
-
170
+
171
+ let rtcConfig = makeAudioConfiguration()
172
+ log("Setup with category: \(rtcConfig.category), mode: \(rtcConfig.mode), options: \(rtcConfig.categoryOptions)")
173
+
174
+ // Set category only — the sink owns setActive().
138
175
  let session = RTCAudioSession.sharedInstance()
139
176
  session.lockForConfiguration()
140
- defer {
141
- session.unlockForConfiguration()
142
- }
177
+ defer { session.unlockForConfiguration() }
143
178
  do {
144
179
  try session.setConfiguration(rtcConfig)
145
- if (wasRecording) {
146
- try adm.setRecording(wasRecording)
147
- }
148
- if (wasPlaying) {
149
- try adm.setPlayout(wasPlaying)
150
- }
151
180
  } catch {
152
- log("Error setting audio session: \(error.localizedDescription)")
181
+ // String(describing:) shows the real error; localizedDescription prints a useless "error 0".
182
+ log("Error setting audio session: \(String(describing: error))")
183
+ }
184
+
185
+ // Subscribe to the AudioDeviceModule's engine lifecycle (idempotent).
186
+ // The sink does the authoritative reapply on every engine rebuild
187
+ // (interruption recovery, mode swap) and owns `setActive(true/false)`
188
+ // since there's no CallKit on this path.
189
+ // Skipped when callingx owns the session (CallKit-managed call active).
190
+ if engineSubscription == nil {
191
+ #if DEBUG
192
+ NSLog("%@","[StreamInCallManager][wireEngineSubscription]")
193
+ #endif
194
+ engineSubscription = adm.publisher.sink { [weak self] event in
195
+ guard let self else { return }
196
+ self.audioSessionQueue.async {
197
+ switch event {
198
+ case .willEnableAudioEngine:
199
+ self.applyConfigForEngineEnable()
200
+ case .didDisableAudioEngine:
201
+ self.applyConfigForEngineDisable()
202
+ default:
203
+ // .willStartAudioEngine / .didStopAudioEngine intentionally ignored:
204
+ // WebRTC's engine stop/restart around interruptions — it re-activates the
205
+ // session itself via AVAudioEngine.start(). We only (re)apply config +
206
+ // setActive on enable/disable.
207
+ break
208
+ }
209
+ }
210
+ }
153
211
  }
154
212
  }
155
213
  }
156
214
 
215
+ // MARK: - Engine Lifecycle Handlers (non-CallKit path)
216
+
217
+ /// Reapplies the same `AVAudioSessionConfiguration` that `setup()` writes
218
+ /// on every engine rebuild, then activates the session.
219
+ /// No-ops when callingx owns the session.
220
+ private func applyConfigForEngineEnable() {
221
+ if Self.callingxOwnsSession() {
222
+ log("engineWillEnable: callingx owns the session, skipping")
223
+ return
224
+ }
225
+
226
+ let rtcConfig = makeAudioConfiguration()
227
+ let session = RTCAudioSession.sharedInstance()
228
+ session.lockForConfiguration()
229
+ defer { session.unlockForConfiguration() }
230
+ do {
231
+ try session.setConfiguration(rtcConfig)
232
+ try session.setActive(true)
233
+ log("engineWillEnable: applied category=\(rtcConfig.category) mode=\(rtcConfig.mode) activated=true")
234
+ } catch {
235
+ log("engineWillEnable error: \(String(describing: error))")
236
+ }
237
+ }
238
+
239
+ private func applyConfigForEngineDisable() {
240
+ if Self.callingxOwnsSession() {
241
+ return
242
+ }
243
+
244
+ let session = RTCAudioSession.sharedInstance()
245
+ session.lockForConfiguration()
246
+ defer { session.unlockForConfiguration() }
247
+ do {
248
+ try session.setActive(false)
249
+ log("engineDidDisable: deactivated session")
250
+ } catch {
251
+ log("engineDidDisable error: \(String(describing: error))")
252
+ }
253
+ }
254
+
255
+ /// Runtime KVC lookup of `CallingxSessionOwnership.callingxOwnsSession`.
256
+ /// `@stream-io/react-native-callingx` is an optional peer dep, so a direct
257
+ /// Swift `import` is not safe
258
+ private static func callingxOwnsSession() -> Bool {
259
+ guard let cls = NSClassFromString("Callingx.CallingxSessionOwnership") else {
260
+ return false
261
+ }
262
+ return ((cls as AnyObject).value(forKey: "callingxOwnsSession") as? Bool) ?? false
263
+ }
264
+
157
265
  @objc
158
266
  func start() {
159
267
  setup()
@@ -166,25 +274,23 @@ class StreamInCallManager: RCTEventEmitter {
166
274
  UIApplication.shared.isIdleTimerDisabled = true
167
275
  // Register for audio route changes to turn off screen when earpiece is connected
168
276
  self.registerAudioRouteObserver()
277
+ self.registerInterruptionObserver()
169
278
  self.updateProximityMonitoring()
170
279
  self.log("Wake lock enabled (idle timer disabled)")
171
280
  self.log("defaultAudioDevice: \(self.defaultAudioDevice)")
172
281
  }
173
- let session = RTCAudioSession.sharedInstance()
174
- session.lockForConfiguration()
175
- defer {
176
- session.unlockForConfiguration()
177
- }
282
+ // setPlayout(true) triggers .willEnableAudioEngine, whose sink
283
+ // applies the preset and calls setActive(true). No explicit
284
+ // session.setActive(true) here.
178
285
  do {
179
- try session.setActive(true)
180
- self.log("audio session activated")
181
286
  let adm = getAudioDeviceModule()
182
287
  try adm.setPlayout(true)
183
288
  self.log("adm.setPlayout(true) done")
184
289
  } catch {
185
- log("Error activating audio session: \(error.localizedDescription)")
290
+ // String(describing:) surfaces the real error code (see setup()).
291
+ log("Error starting playout: \(String(describing: error))")
186
292
  }
187
-
293
+
188
294
  audioManagerActivated = true
189
295
  }
190
296
  }
@@ -195,18 +301,13 @@ class StreamInCallManager: RCTEventEmitter {
195
301
  if !audioManagerActivated {
196
302
  return
197
303
  }
198
- let session = RTCAudioSession.sharedInstance()
199
- session.lockForConfiguration()
200
- defer {
201
- session.unlockForConfiguration()
202
- }
203
- do {
204
- try session.setActive(false)
205
- let adm = getAudioDeviceModule()
206
- adm.reset()
207
- } catch {
208
- log("Error deactivating audio session: \(error.localizedDescription)")
209
- }
304
+ let adm = getAudioDeviceModule()
305
+ adm.reset()
306
+ // Deactivate directly: the .didDisableAudioEngine sink is async and we cancel it below.
307
+ applyConfigForEngineDisable()
308
+ // Tear down the engine-observer subscription so a re-setup wires a fresh one.
309
+ engineSubscription?.cancel()
310
+ engineSubscription = nil
210
311
  // Cancel any pending debounced stereo refresh
211
312
  stereoRefreshWorkItem?.cancel()
212
313
  stereoRefreshWorkItem = nil
@@ -220,6 +321,7 @@ class StreamInCallManager: RCTEventEmitter {
220
321
  // Disable proximity monitoring to disable earpiece detection
221
322
  self.setProximityMonitoringEnabled(false)
222
323
  self.unregisterAudioRouteObserver()
324
+ self.unregisterInterruptionObserver()
223
325
  // Disable wake lock to allow the screen to dim/lock again
224
326
  UIApplication.shared.isIdleTimerDisabled = false
225
327
  self.log("Wake lock disabled (idle timer enabled)")
@@ -391,6 +493,66 @@ class StreamInCallManager: RCTEventEmitter {
391
493
  log("Unregistered AVAudioSession.routeChangeNotification observer")
392
494
  }
393
495
 
496
+ // MARK: - Interruption Handling
497
+
498
+ /// Observes `AVAudioSession.interruptionNotification` to trace and log *why* an interruption fired
499
+ /// (mic-mute / route-disconnect / PSTN-Siri). Recovery is NOT done here: WebRTC's
500
+ /// AudioEngineDevice owns it — it stops the engine on interruption-begin and restarts it on
501
+ /// interruption-end (re-activating the session itself via AVAudioEngine.start)
502
+ private func registerInterruptionObserver() {
503
+ if hasRegisteredInterruptionObserver { return }
504
+ NotificationCenter.default.addObserver(
505
+ self,
506
+ selector: #selector(handleAudioInterruption(_:)),
507
+ name: AVAudioSession.interruptionNotification,
508
+ object: nil
509
+ )
510
+ hasRegisteredInterruptionObserver = true
511
+ }
512
+
513
+ private func unregisterInterruptionObserver() {
514
+ if !hasRegisteredInterruptionObserver { return }
515
+ NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
516
+ hasRegisteredInterruptionObserver = false
517
+ }
518
+
519
+ @objc
520
+ private func handleAudioInterruption(_ notification: Notification) {
521
+ guard let info = notification.userInfo,
522
+ let typeRaw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
523
+ let type = AVAudioSession.InterruptionType(rawValue: typeRaw) else {
524
+ return
525
+ }
526
+
527
+ let reason = interruptionReason(info)
528
+ var payload: [String: Any] = ["source": "callmanager"]
529
+ if let reason {
530
+ payload["reason"] = reason
531
+ }
532
+
533
+ switch type {
534
+ case .began:
535
+ payload["phase"] = "began"
536
+ sendEvent(withName: StreamInCallManagerEvents.audioInterruption, body: payload)
537
+ #if DEBUG
538
+ log("Audio interruption began (reason=\(reason ?? "n/a")). Recovery owned by WebRTC AudioEngineDevice.")
539
+ #endif
540
+ case .ended:
541
+ var shouldResume = false
542
+ if let optsRaw = info[AVAudioSessionInterruptionOptionKey] as? UInt {
543
+ shouldResume = AVAudioSession.InterruptionOptions(rawValue: optsRaw).contains(.shouldResume)
544
+ }
545
+ payload["phase"] = "ended"
546
+ payload["shouldResume"] = shouldResume
547
+ sendEvent(withName: StreamInCallManagerEvents.audioInterruption, body: payload)
548
+ #if DEBUG
549
+ log("Audio interruption ended (shouldResume=\(shouldResume)). WebRTC restarts the engine.")
550
+ #endif
551
+ @unknown default:
552
+ break
553
+ }
554
+ }
555
+
394
556
  @objc
395
557
  private func handleAudioRouteChange(_ notification: Notification) {
396
558
  guard let userInfo = notification.userInfo,
@@ -454,18 +616,7 @@ class StreamInCallManager: RCTEventEmitter {
454
616
  // MARK: - RCTEventEmitter
455
617
 
456
618
  override func supportedEvents() -> [String]! {
457
- // TODO: list events that can be sent to JS
458
- return []
459
- }
460
-
461
- @objc
462
- override func addListener(_ eventName: String!) {
463
- super.addListener(eventName)
464
- }
465
-
466
- @objc
467
- override func removeListeners(_ count: Double) {
468
- super.removeListeners(count)
619
+ return [StreamInCallManagerEvents.audioInterruption]
469
620
  }
470
621
 
471
622
  // MARK: - Helper Methods
@@ -511,6 +662,30 @@ class StreamInCallManager: RCTEventEmitter {
511
662
  }
512
663
  }
513
664
 
665
+ /// Best-effort name for the iOS 14.5+ `AVAudioSessionInterruptionReasonKey`.
666
+ /// Names the iOS 17 cases we care about and falls back to the raw value otherwise.
667
+ private func interruptionReason(_ info: [AnyHashable: Any]) -> String? {
668
+ guard #available(iOS 14.5, *),
669
+ let reasonRaw = info[AVAudioSessionInterruptionReasonKey] as? UInt,
670
+ let reason = AVAudioSession.InterruptionReason(rawValue: reasonRaw) else {
671
+ return nil
672
+ }
673
+ if #available(iOS 17.0, *) {
674
+ switch reason {
675
+ case .builtInMicMuted:
676
+ return "builtInMicMuted"
677
+ case .routeDisconnected:
678
+ return "routeDisconnected"
679
+ default:
680
+ break
681
+ }
682
+ }
683
+ if reason == .default {
684
+ return "default"
685
+ }
686
+ return "raw(\(reason.rawValue))"
687
+ }
688
+
514
689
  // MARK: - Logging Helper
515
690
  private func log(_ message: String) {
516
691
  NSLog("InCallManager: %@", message)
@@ -11,8 +11,6 @@
11
11
  #import <WebRTC/RTCVideoTrack.h>
12
12
  #import <WebRTC/RTCVideoRenderer.h>
13
13
  #import <WebRTC/RTCVideoFrameBuffer.h>
14
- #import <WebRTC/RTCAudioTrack.h>
15
- #import <WebRTC/RTCAudioRenderer.h>
16
14
  #import "WebRTCModule.h"
17
15
  #import "WebRTCModuleOptions.h"
18
16
 
@@ -20,13 +20,6 @@
20
20
  #import <stream_react_native_webrtc/stream_react_native_webrtc-Swift.h>
21
21
  #endif
22
22
 
23
- // Import Swift-generated header for TracksRecorderManager and friends.
24
- #if __has_include("stream_video_react_native-Swift.h")
25
- #import "stream_video_react_native-Swift.h"
26
- #elif __has_include(<stream_video_react_native/stream_video_react_native-Swift.h>)
27
- #import <stream_video_react_native/stream_video_react_native-Swift.h>
28
- #endif
29
-
30
23
  // Do not change these consts, it is what is used react-native-webrtc
31
24
  NSNotificationName const kBroadcastStartedNotification = @"iOS_BroadcastStarted";
32
25
  NSNotificationName const kBroadcastStoppedNotification = @"iOS_BroadcastStopped";
@@ -692,78 +685,4 @@ RCT_EXPORT_METHOD(stopScreenShareAudioMixing:(RCTPromiseResolveBlock)resolve
692
685
  resolve(nil);
693
686
  }
694
687
 
695
- #pragma mark - Track Recording
696
-
697
- RCT_EXPORT_METHOD(startTrackRecording:(NSDictionary *)options
698
- resolver:(RCTPromiseResolveBlock)resolve
699
- rejecter:(RCTPromiseRejectBlock)reject)
700
- {
701
- WebRTCModule *webrtcModule = [self.bridge moduleForClass:[WebRTCModule class]];
702
- if (!webrtcModule) {
703
- reject(@"recording_error", @"WebRTCModule not available", nil);
704
- return;
705
- }
706
-
707
- NSString *videoTrackId = options[@"videoTrackId"];
708
- if (![videoTrackId isKindOfClass:[NSString class]]) videoTrackId = nil;
709
-
710
- NSNumber *maxDuration = options[@"maxDurationMs"];
711
- NSInteger maxDurationMs = ([maxDuration isKindOfClass:[NSNumber class]])
712
- ? [maxDuration integerValue] : 5000;
713
-
714
- NSNumber *targetW = options[@"targetWidth"];
715
- NSInteger targetWidth = ([targetW isKindOfClass:[NSNumber class]])
716
- ? [targetW integerValue] : 0;
717
-
718
- NSNumber *targetH = options[@"targetHeight"];
719
- NSInteger targetHeight = ([targetH isKindOfClass:[NSNumber class]])
720
- ? [targetH integerValue] : 0;
721
-
722
- [[TracksRecorderManager shared]
723
- startRecordingWithVideoTrackId:videoTrackId
724
- maxDurationMs:maxDurationMs
725
- targetWidth:targetWidth
726
- targetHeight:targetHeight
727
- webRTCModule:webrtcModule
728
- completion:^(NSURL * _Nullable fileURL, NSError * _Nullable err) {
729
- if (err) {
730
- reject(@"recording_error", err.localizedDescription, err);
731
- } else {
732
- resolve(fileURL ? fileURL.absoluteString : [NSNull null]);
733
- }
734
- }];
735
- }
736
-
737
- RCT_EXPORT_METHOD(stopTrackRecording:(RCTPromiseResolveBlock)resolve
738
- rejecter:(RCTPromiseRejectBlock)reject)
739
- {
740
- [[TracksRecorderManager shared] stopRecordingWithCompletion:^{
741
- resolve(nil);
742
- }];
743
- }
744
-
745
- RCT_EXPORT_METHOD(clearStreamRecordings:(RCTPromiseResolveBlock)resolve
746
- rejecter:(RCTPromiseRejectBlock)reject)
747
- {
748
- [[TracksRecorderManager shared] clearRecordingsDirectoryWithCompletion:^(NSError * _Nullable err) {
749
- if (err) {
750
- reject(@"clear_error", err.localizedDescription, err);
751
- } else {
752
- resolve(nil);
753
- }
754
- }];
755
- }
756
-
757
- RCT_EXPORT_METHOD(getStreamRecordings:(RCTPromiseResolveBlock)resolve
758
- rejecter:(RCTPromiseRejectBlock)reject)
759
- {
760
- NSArray<NSURL *> *urls = [[TracksRecorderManager shared] listRecordings];
761
- NSMutableArray<NSString *> *result = [NSMutableArray arrayWithCapacity:urls.count];
762
- for (NSURL *url in urls) {
763
- NSString *abs = url.absoluteString;
764
- if (abs) [result addObject:abs];
765
- }
766
- resolve(result);
767
- }
768
-
769
688
  @end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-react-native-sdk",
3
- "version": "1.37.1-beta.0",
3
+ "version": "1.38.1",
4
4
  "description": "Stream Video SDK for React Native",
5
5
  "author": "https://getstream.io",
6
6
  "homepage": "https://getstream.io/video/docs/react-native/",
@@ -50,8 +50,8 @@
50
50
  "!**/.*"
51
51
  ],
52
52
  "dependencies": {
53
- "@stream-io/video-client": "1.52.1-beta.0",
54
- "@stream-io/video-react-bindings": "1.16.3-beta.0",
53
+ "@stream-io/video-client": "1.53.1",
54
+ "@stream-io/video-react-bindings": "1.16.4",
55
55
  "intl-pluralrules": "2.0.1",
56
56
  "react-native-url-polyfill": "^3.0.0",
57
57
  "rxjs": "~7.8.2",
@@ -63,7 +63,7 @@
63
63
  "@react-native-firebase/messaging": ">=17.5.0",
64
64
  "@stream-io/noise-cancellation-react-native": ">=0.1.0",
65
65
  "@stream-io/react-native-callingx": ">=0.1.0",
66
- "@stream-io/react-native-webrtc": ">=137.2.2",
66
+ "@stream-io/react-native-webrtc": "^145.0.0",
67
67
  "@stream-io/video-filters-react-native": ">=0.1.0",
68
68
  "expo": ">=47.0.0",
69
69
  "expo-build-properties": "*",
@@ -116,20 +116,20 @@
116
116
  "@react-native-firebase/messaging": "^24.0.0",
117
117
  "@react-native/babel-preset": "0.85.3",
118
118
  "@react-native/metro-config": "0.85.3",
119
- "@stream-io/noise-cancellation-react-native": "0.7.0",
120
- "@stream-io/react-native-callingx": "0.4.0",
121
- "@stream-io/react-native-webrtc": "137.2.2-alpha.0",
122
- "@stream-io/video-filters-react-native": "0.12.4",
119
+ "@stream-io/noise-cancellation-react-native": "^0.8.0",
120
+ "@stream-io/react-native-callingx": "^0.5.1",
121
+ "@stream-io/react-native-webrtc": "145.0.0",
122
+ "@stream-io/video-filters-react-native": "^0.13.0",
123
123
  "@testing-library/jest-native": "^5.4.3",
124
124
  "@testing-library/react-native": "13.3.3",
125
125
  "@tsconfig/node18": "^18.2.6",
126
126
  "@types/jest": "^29.5.14",
127
127
  "@types/react": "~19.2.15",
128
128
  "@types/react-test-renderer": "^19.1.0",
129
- "expo": "~56.0.8",
130
- "expo-build-properties": "~56.0.16",
129
+ "expo": "~56.0.9",
130
+ "expo-build-properties": "~56.0.17",
131
131
  "expo-module-scripts": "^56.0.3",
132
- "expo-notifications": "~56.0.15",
132
+ "expo-notifications": "~56.0.16",
133
133
  "jest": "^29.7.0",
134
134
  "react": "19.2.3",
135
135
  "react-native": "0.85.3",
@@ -7,7 +7,6 @@ export * from './useIsInPiPMode';
7
7
  export * from './useAutoEnterPiPEffect';
8
8
  export * from './useScreenShareButton';
9
9
  export * from './useScreenShareAudioMixing';
10
- export * from './useLoopbackRecording';
11
10
  export * from './useTrackDimensions';
12
11
  export * from './useScreenshot';
13
12
  export * from './useModeration';
@@ -1,10 +1,15 @@
1
1
  import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
2
- import { AudioDeviceStatus, StreamInCallManagerConfig } from './types';
2
+ import type {
3
+ AudioDeviceStatus,
4
+ IOSAudioInterruptionEvent,
5
+ StreamInCallManagerConfig,
6
+ } from './types';
3
7
  import { getCallingxLibIfAvailable } from '../../utils/push/libs/callingx';
4
8
  import { videoLoggerSystem } from '@stream-io/video-client';
5
9
 
6
10
  const NativeManager = NativeModules.StreamInCallManager;
7
11
  const CallingxModule = getCallingxLibIfAvailable();
12
+ const AUDIO_INTERRUPTION_EVENT = 'StreamInCallManagerAudioInterruption';
8
13
 
9
14
  const invariant = (condition: boolean, message: string) => {
10
15
  if (!condition) throw new Error(message);
@@ -46,6 +51,8 @@ class AndroidCallManager {
46
51
  }
47
52
 
48
53
  class IOSCallManager {
54
+ private eventEmitter?: NativeEventEmitter;
55
+
49
56
  /**
50
57
  * Will trigger the iOS device selector.
51
58
  */
@@ -53,6 +60,23 @@ class IOSCallManager {
53
60
  invariant(Platform.OS === 'ios', 'Supported only on iOS');
54
61
  NativeManager.showAudioRoutePicker();
55
62
  };
63
+
64
+ /**
65
+ * Register a listener for iOS audio interruptions.
66
+ *
67
+ * @param onInterruption callback to be called when iOS reports an audio interruption.
68
+ */
69
+ addAudioInterruptionListener = (
70
+ onInterruption: (event: IOSAudioInterruptionEvent) => void,
71
+ ): (() => void) => {
72
+ invariant(Platform.OS === 'ios', 'Supported only on iOS');
73
+ this.eventEmitter ??= new NativeEventEmitter(NativeManager);
74
+ const s = this.eventEmitter.addListener(
75
+ AUDIO_INTERRUPTION_EVENT,
76
+ onInterruption,
77
+ );
78
+ return () => s.remove();
79
+ };
56
80
  }
57
81
 
58
82
  class SpeakerManager {
@@ -15,6 +15,13 @@ export type AudioDeviceStatus = {
15
15
  export type AudioRole = 'communicator' | 'listener';
16
16
  export type DeviceEndpointType = 'speaker' | 'earpiece';
17
17
 
18
+ export type IOSAudioInterruptionEvent = {
19
+ source: 'callmanager' | 'callingx';
20
+ phase: 'began' | 'ended';
21
+ reason?: 'default' | 'builtInMicMuted' | 'routeDisconnected' | (string & {});
22
+ shouldResume?: boolean;
23
+ };
24
+
18
25
  export type StreamInCallManagerConfig =
19
26
  | {
20
27
  audioRole: 'communicator';