@stream-io/video-react-native-sdk 1.37.1-beta.0 → 1.38.0
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/CHANGELOG.md +20 -0
- package/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt +0 -81
- package/dist/commonjs/hooks/index.js +0 -11
- package/dist/commonjs/hooks/index.js.map +1 -1
- package/dist/commonjs/modules/call-manager/CallManager.js +13 -0
- package/dist/commonjs/modules/call-manager/CallManager.js.map +1 -1
- package/dist/commonjs/providers/StreamCall/AudioInterruptionTracer.js +37 -0
- package/dist/commonjs/providers/StreamCall/AudioInterruptionTracer.js.map +1 -0
- package/dist/commonjs/providers/StreamCall/index.js +2 -1
- package/dist/commonjs/providers/StreamCall/index.js.map +1 -1
- package/dist/commonjs/utils/internal/callingx/callingx.js +2 -2
- package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
- package/dist/commonjs/utils/push/internal/ios.js +5 -0
- package/dist/commonjs/utils/push/internal/ios.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/commonjs/version.js.map +1 -1
- package/dist/module/hooks/index.js +0 -1
- package/dist/module/hooks/index.js.map +1 -1
- package/dist/module/modules/call-manager/CallManager.js +13 -0
- package/dist/module/modules/call-manager/CallManager.js.map +1 -1
- package/dist/module/providers/StreamCall/AudioInterruptionTracer.js +30 -0
- package/dist/module/providers/StreamCall/AudioInterruptionTracer.js.map +1 -0
- package/dist/module/providers/StreamCall/index.js +2 -1
- package/dist/module/providers/StreamCall/index.js.map +1 -1
- package/dist/module/utils/internal/callingx/callingx.js +2 -2
- package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
- package/dist/module/utils/push/internal/ios.js +5 -0
- package/dist/module/utils/push/internal/ios.js.map +1 -1
- package/dist/module/version.js +1 -1
- package/dist/module/version.js.map +1 -1
- package/dist/typescript/hooks/index.d.ts +0 -1
- package/dist/typescript/hooks/index.d.ts.map +1 -1
- package/dist/typescript/modules/call-manager/CallManager.d.ts +8 -1
- package/dist/typescript/modules/call-manager/CallManager.d.ts.map +1 -1
- package/dist/typescript/modules/call-manager/types.d.ts +6 -0
- package/dist/typescript/modules/call-manager/types.d.ts.map +1 -1
- package/dist/typescript/providers/StreamCall/AudioInterruptionTracer.d.ts +5 -0
- package/dist/typescript/providers/StreamCall/AudioInterruptionTracer.d.ts.map +1 -0
- package/dist/typescript/providers/StreamCall/index.d.ts.map +1 -1
- package/dist/typescript/utils/push/internal/ios.d.ts.map +1 -1
- package/dist/typescript/version.d.ts +1 -1
- package/dist/typescript/version.d.ts.map +1 -1
- package/ios/PictureInPicture/StreamBufferTransformer.swift +13 -4
- package/ios/PictureInPicture/StreamPictureInPictureVideoRenderer.swift +76 -70
- package/ios/PictureInPicture/StreamRTCYUVBuffer.swift +20 -16
- package/ios/StreamInCallManager.swift +237 -81
- package/ios/StreamVideoReactNative-Bridging-Header.h +0 -2
- package/ios/StreamVideoReactNative.m +0 -81
- package/package.json +11 -11
- package/src/hooks/index.ts +0 -1
- package/src/modules/call-manager/CallManager.ts +25 -1
- package/src/modules/call-manager/types.ts +7 -0
- package/src/providers/StreamCall/AudioInterruptionTracer.tsx +51 -0
- package/src/providers/StreamCall/index.tsx +2 -0
- package/src/utils/internal/callingx/callingx.ts +2 -2
- package/src/utils/push/internal/ios.ts +5 -0
- package/src/version.ts +1 -1
- package/android/src/main/java/com/streamvideo/reactnative/recorder/AudioPipeline.kt +0 -436
- package/android/src/main/java/com/streamvideo/reactnative/recorder/EncoderConstants.kt +0 -17
- package/android/src/main/java/com/streamvideo/reactnative/recorder/PipelineHost.kt +0 -36
- package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderPlaybackSamplesSink.kt +0 -60
- package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderVideoSink.kt +0 -31
- package/android/src/main/java/com/streamvideo/reactnative/recorder/TracksRecorderManager.kt +0 -329
- package/android/src/main/java/com/streamvideo/reactnative/recorder/VideoPipeline.kt +0 -472
- package/dist/commonjs/hooks/useLoopbackRecording.js +0 -243
- package/dist/commonjs/hooks/useLoopbackRecording.js.map +0 -1
- package/dist/module/hooks/useLoopbackRecording.js +0 -238
- package/dist/module/hooks/useLoopbackRecording.js.map +0 -1
- package/dist/typescript/hooks/useLoopbackRecording.d.ts +0 -85
- package/dist/typescript/hooks/useLoopbackRecording.d.ts.map +0 -1
- package/ios/TracksRecorder/AudioPipeline.swift +0 -270
- package/ios/TracksRecorder/PipelineHost.swift +0 -56
- package/ios/TracksRecorder/RecorderAudioRenderTap.swift +0 -154
- package/ios/TracksRecorder/RecorderVideoSink.swift +0 -137
- package/ios/TracksRecorder/TracksRecorderManager.swift +0 -327
- package/ios/TracksRecorder/VideoPipeline.swift +0 -297
- 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,149 @@ 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 {
|
|
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
|
+
category = .playAndRecord
|
|
129
|
+
mode = .voiceChat
|
|
130
|
+
options = defaultAudioDevice == .speaker ? [bluetoothOption, .defaultToSpeaker] : [bluetoothOption]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let rtcConfig = RTCAudioSessionConfiguration.webRTC()
|
|
134
|
+
rtcConfig.category = category.rawValue
|
|
135
|
+
rtcConfig.mode = mode.rawValue
|
|
136
|
+
rtcConfig.categoryOptions = options
|
|
137
|
+
// Keep WebRTC's internal state consistent during interruptions/route changes.
|
|
138
|
+
RTCAudioSessionConfiguration.setWebRTC(rtcConfig)
|
|
139
|
+
return rtcConfig
|
|
140
|
+
}
|
|
141
|
+
|
|
94
142
|
@objc
|
|
95
143
|
func setup() {
|
|
96
144
|
audioSessionQueue.async { [self] in
|
|
97
|
-
let intendedCategory: AVAudioSession.Category!
|
|
98
|
-
let intendedMode: AVAudioSession.Mode!
|
|
99
|
-
let intendedOptions: AVAudioSession.CategoryOptions!
|
|
100
|
-
|
|
101
145
|
let adm = getAudioDeviceModule()
|
|
102
|
-
let wasRecording = adm.isRecording
|
|
103
|
-
let wasPlaying = adm.isPlaying
|
|
104
|
-
adm.reset()
|
|
105
146
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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]
|
|
147
|
+
// Stereo is listener-only and applies live. Cleared by stop()'s reset().
|
|
148
|
+
if callAudioRole == .listener && enableStereo {
|
|
149
|
+
adm.setStereoPlayoutPreference(true)
|
|
129
150
|
}
|
|
130
|
-
|
|
131
|
-
let rtcConfig =
|
|
132
|
-
rtcConfig.category
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// This ensures WebRTC's internal state stays consistent during interruptions/route changes
|
|
136
|
-
RTCAudioSessionConfiguration.setWebRTC(rtcConfig)
|
|
137
|
-
|
|
151
|
+
|
|
152
|
+
let rtcConfig = makeAudioConfiguration()
|
|
153
|
+
log("Setup with category: \(rtcConfig.category), mode: \(rtcConfig.mode), options: \(rtcConfig.categoryOptions)")
|
|
154
|
+
|
|
155
|
+
// Set category only — the sink owns setActive().
|
|
138
156
|
let session = RTCAudioSession.sharedInstance()
|
|
139
157
|
session.lockForConfiguration()
|
|
140
|
-
defer {
|
|
141
|
-
session.unlockForConfiguration()
|
|
142
|
-
}
|
|
158
|
+
defer { session.unlockForConfiguration() }
|
|
143
159
|
do {
|
|
144
160
|
try session.setConfiguration(rtcConfig)
|
|
145
|
-
if (wasRecording) {
|
|
146
|
-
try adm.setRecording(wasRecording)
|
|
147
|
-
}
|
|
148
|
-
if (wasPlaying) {
|
|
149
|
-
try adm.setPlayout(wasPlaying)
|
|
150
|
-
}
|
|
151
161
|
} catch {
|
|
152
|
-
|
|
162
|
+
// String(describing:) shows the real error; localizedDescription prints a useless "error 0".
|
|
163
|
+
log("Error setting audio session: \(String(describing: error))")
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Subscribe to the AudioDeviceModule's engine lifecycle (idempotent).
|
|
167
|
+
// The sink does the authoritative reapply on every engine rebuild
|
|
168
|
+
// (interruption recovery, mode swap) and owns `setActive(true/false)`
|
|
169
|
+
// since there's no CallKit on this path.
|
|
170
|
+
// Skipped when callingx owns the session (CallKit-managed call active).
|
|
171
|
+
if engineSubscription == nil {
|
|
172
|
+
#if DEBUG
|
|
173
|
+
NSLog("%@","[StreamInCallManager][wireEngineSubscription]")
|
|
174
|
+
#endif
|
|
175
|
+
engineSubscription = adm.publisher.sink { [weak self] event in
|
|
176
|
+
guard let self else { return }
|
|
177
|
+
self.audioSessionQueue.async {
|
|
178
|
+
switch event {
|
|
179
|
+
case .willEnableAudioEngine:
|
|
180
|
+
self.applyConfigForEngineEnable()
|
|
181
|
+
case .didDisableAudioEngine:
|
|
182
|
+
self.applyConfigForEngineDisable()
|
|
183
|
+
default:
|
|
184
|
+
// .willStartAudioEngine / .didStopAudioEngine intentionally ignored:
|
|
185
|
+
// WebRTC's engine stop/restart around interruptions — it re-activates the
|
|
186
|
+
// session itself via AVAudioEngine.start(). We only (re)apply config +
|
|
187
|
+
// setActive on enable/disable.
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
153
192
|
}
|
|
154
193
|
}
|
|
155
194
|
}
|
|
156
195
|
|
|
196
|
+
// MARK: - Engine Lifecycle Handlers (non-CallKit path)
|
|
197
|
+
|
|
198
|
+
/// Reapplies the same `AVAudioSessionConfiguration` that `setup()` writes
|
|
199
|
+
/// on every engine rebuild, then activates the session.
|
|
200
|
+
/// No-ops when callingx owns the session.
|
|
201
|
+
private func applyConfigForEngineEnable() {
|
|
202
|
+
if Self.callingxOwnsSession() {
|
|
203
|
+
log("engineWillEnable: callingx owns the session, skipping")
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let rtcConfig = makeAudioConfiguration()
|
|
208
|
+
let session = RTCAudioSession.sharedInstance()
|
|
209
|
+
session.lockForConfiguration()
|
|
210
|
+
defer { session.unlockForConfiguration() }
|
|
211
|
+
do {
|
|
212
|
+
try session.setConfiguration(rtcConfig)
|
|
213
|
+
try session.setActive(true)
|
|
214
|
+
log("engineWillEnable: applied category=\(rtcConfig.category) mode=\(rtcConfig.mode) activated=true")
|
|
215
|
+
} catch {
|
|
216
|
+
log("engineWillEnable error: \(String(describing: error))")
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private func applyConfigForEngineDisable() {
|
|
221
|
+
if Self.callingxOwnsSession() {
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let session = RTCAudioSession.sharedInstance()
|
|
226
|
+
session.lockForConfiguration()
|
|
227
|
+
defer { session.unlockForConfiguration() }
|
|
228
|
+
do {
|
|
229
|
+
try session.setActive(false)
|
|
230
|
+
log("engineDidDisable: deactivated session")
|
|
231
|
+
} catch {
|
|
232
|
+
log("engineDidDisable error: \(String(describing: error))")
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/// Runtime KVC lookup of `CallingxSessionOwnership.callingxOwnsSession`.
|
|
237
|
+
/// `@stream-io/react-native-callingx` is an optional peer dep, so a direct
|
|
238
|
+
/// Swift `import` is not safe
|
|
239
|
+
private static func callingxOwnsSession() -> Bool {
|
|
240
|
+
guard let cls = NSClassFromString("Callingx.CallingxSessionOwnership") else {
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
return ((cls as AnyObject).value(forKey: "callingxOwnsSession") as? Bool) ?? false
|
|
244
|
+
}
|
|
245
|
+
|
|
157
246
|
@objc
|
|
158
247
|
func start() {
|
|
159
248
|
setup()
|
|
@@ -166,25 +255,23 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
166
255
|
UIApplication.shared.isIdleTimerDisabled = true
|
|
167
256
|
// Register for audio route changes to turn off screen when earpiece is connected
|
|
168
257
|
self.registerAudioRouteObserver()
|
|
258
|
+
self.registerInterruptionObserver()
|
|
169
259
|
self.updateProximityMonitoring()
|
|
170
260
|
self.log("Wake lock enabled (idle timer disabled)")
|
|
171
261
|
self.log("defaultAudioDevice: \(self.defaultAudioDevice)")
|
|
172
262
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
session.unlockForConfiguration()
|
|
177
|
-
}
|
|
263
|
+
// setPlayout(true) triggers .willEnableAudioEngine, whose sink
|
|
264
|
+
// applies the preset and calls setActive(true). No explicit
|
|
265
|
+
// session.setActive(true) here.
|
|
178
266
|
do {
|
|
179
|
-
try session.setActive(true)
|
|
180
|
-
self.log("audio session activated")
|
|
181
267
|
let adm = getAudioDeviceModule()
|
|
182
268
|
try adm.setPlayout(true)
|
|
183
269
|
self.log("adm.setPlayout(true) done")
|
|
184
270
|
} catch {
|
|
185
|
-
|
|
271
|
+
// String(describing:) surfaces the real error code (see setup()).
|
|
272
|
+
log("Error starting playout: \(String(describing: error))")
|
|
186
273
|
}
|
|
187
|
-
|
|
274
|
+
|
|
188
275
|
audioManagerActivated = true
|
|
189
276
|
}
|
|
190
277
|
}
|
|
@@ -195,18 +282,13 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
195
282
|
if !audioManagerActivated {
|
|
196
283
|
return
|
|
197
284
|
}
|
|
198
|
-
let
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
let adm = getAudioDeviceModule()
|
|
206
|
-
adm.reset()
|
|
207
|
-
} catch {
|
|
208
|
-
log("Error deactivating audio session: \(error.localizedDescription)")
|
|
209
|
-
}
|
|
285
|
+
let adm = getAudioDeviceModule()
|
|
286
|
+
adm.reset()
|
|
287
|
+
// Deactivate directly: the .didDisableAudioEngine sink is async and we cancel it below.
|
|
288
|
+
applyConfigForEngineDisable()
|
|
289
|
+
// Tear down the engine-observer subscription so a re-setup wires a fresh one.
|
|
290
|
+
engineSubscription?.cancel()
|
|
291
|
+
engineSubscription = nil
|
|
210
292
|
// Cancel any pending debounced stereo refresh
|
|
211
293
|
stereoRefreshWorkItem?.cancel()
|
|
212
294
|
stereoRefreshWorkItem = nil
|
|
@@ -220,6 +302,7 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
220
302
|
// Disable proximity monitoring to disable earpiece detection
|
|
221
303
|
self.setProximityMonitoringEnabled(false)
|
|
222
304
|
self.unregisterAudioRouteObserver()
|
|
305
|
+
self.unregisterInterruptionObserver()
|
|
223
306
|
// Disable wake lock to allow the screen to dim/lock again
|
|
224
307
|
UIApplication.shared.isIdleTimerDisabled = false
|
|
225
308
|
self.log("Wake lock disabled (idle timer enabled)")
|
|
@@ -391,6 +474,66 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
391
474
|
log("Unregistered AVAudioSession.routeChangeNotification observer")
|
|
392
475
|
}
|
|
393
476
|
|
|
477
|
+
// MARK: - Interruption Handling
|
|
478
|
+
|
|
479
|
+
/// Observes `AVAudioSession.interruptionNotification` to trace and log *why* an interruption fired
|
|
480
|
+
/// (mic-mute / route-disconnect / PSTN-Siri). Recovery is NOT done here: WebRTC's
|
|
481
|
+
/// AudioEngineDevice owns it — it stops the engine on interruption-begin and restarts it on
|
|
482
|
+
/// interruption-end (re-activating the session itself via AVAudioEngine.start)
|
|
483
|
+
private func registerInterruptionObserver() {
|
|
484
|
+
if hasRegisteredInterruptionObserver { return }
|
|
485
|
+
NotificationCenter.default.addObserver(
|
|
486
|
+
self,
|
|
487
|
+
selector: #selector(handleAudioInterruption(_:)),
|
|
488
|
+
name: AVAudioSession.interruptionNotification,
|
|
489
|
+
object: nil
|
|
490
|
+
)
|
|
491
|
+
hasRegisteredInterruptionObserver = true
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private func unregisterInterruptionObserver() {
|
|
495
|
+
if !hasRegisteredInterruptionObserver { return }
|
|
496
|
+
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
|
|
497
|
+
hasRegisteredInterruptionObserver = false
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
@objc
|
|
501
|
+
private func handleAudioInterruption(_ notification: Notification) {
|
|
502
|
+
guard let info = notification.userInfo,
|
|
503
|
+
let typeRaw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
504
|
+
let type = AVAudioSession.InterruptionType(rawValue: typeRaw) else {
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let reason = interruptionReason(info)
|
|
509
|
+
var payload: [String: Any] = ["source": "callmanager"]
|
|
510
|
+
if let reason {
|
|
511
|
+
payload["reason"] = reason
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
switch type {
|
|
515
|
+
case .began:
|
|
516
|
+
payload["phase"] = "began"
|
|
517
|
+
sendEvent(withName: StreamInCallManagerEvents.audioInterruption, body: payload)
|
|
518
|
+
#if DEBUG
|
|
519
|
+
log("Audio interruption began (reason=\(reason ?? "n/a")). Recovery owned by WebRTC AudioEngineDevice.")
|
|
520
|
+
#endif
|
|
521
|
+
case .ended:
|
|
522
|
+
var shouldResume = false
|
|
523
|
+
if let optsRaw = info[AVAudioSessionInterruptionOptionKey] as? UInt {
|
|
524
|
+
shouldResume = AVAudioSession.InterruptionOptions(rawValue: optsRaw).contains(.shouldResume)
|
|
525
|
+
}
|
|
526
|
+
payload["phase"] = "ended"
|
|
527
|
+
payload["shouldResume"] = shouldResume
|
|
528
|
+
sendEvent(withName: StreamInCallManagerEvents.audioInterruption, body: payload)
|
|
529
|
+
#if DEBUG
|
|
530
|
+
log("Audio interruption ended (shouldResume=\(shouldResume)). WebRTC restarts the engine.")
|
|
531
|
+
#endif
|
|
532
|
+
@unknown default:
|
|
533
|
+
break
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
394
537
|
@objc
|
|
395
538
|
private func handleAudioRouteChange(_ notification: Notification) {
|
|
396
539
|
guard let userInfo = notification.userInfo,
|
|
@@ -454,18 +597,7 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
454
597
|
// MARK: - RCTEventEmitter
|
|
455
598
|
|
|
456
599
|
override func supportedEvents() -> [String]! {
|
|
457
|
-
|
|
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)
|
|
600
|
+
return [StreamInCallManagerEvents.audioInterruption]
|
|
469
601
|
}
|
|
470
602
|
|
|
471
603
|
// MARK: - Helper Methods
|
|
@@ -511,6 +643,30 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
511
643
|
}
|
|
512
644
|
}
|
|
513
645
|
|
|
646
|
+
/// Best-effort name for the iOS 14.5+ `AVAudioSessionInterruptionReasonKey`.
|
|
647
|
+
/// Names the iOS 17 cases we care about and falls back to the raw value otherwise.
|
|
648
|
+
private func interruptionReason(_ info: [AnyHashable: Any]) -> String? {
|
|
649
|
+
guard #available(iOS 14.5, *),
|
|
650
|
+
let reasonRaw = info[AVAudioSessionInterruptionReasonKey] as? UInt,
|
|
651
|
+
let reason = AVAudioSession.InterruptionReason(rawValue: reasonRaw) else {
|
|
652
|
+
return nil
|
|
653
|
+
}
|
|
654
|
+
if #available(iOS 17.0, *) {
|
|
655
|
+
switch reason {
|
|
656
|
+
case .builtInMicMuted:
|
|
657
|
+
return "builtInMicMuted"
|
|
658
|
+
case .routeDisconnected:
|
|
659
|
+
return "routeDisconnected"
|
|
660
|
+
default:
|
|
661
|
+
break
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if reason == .default {
|
|
665
|
+
return "default"
|
|
666
|
+
}
|
|
667
|
+
return "raw(\(reason.rawValue))"
|
|
668
|
+
}
|
|
669
|
+
|
|
514
670
|
// MARK: - Logging Helper
|
|
515
671
|
private func log(_ message: String) {
|
|
516
672
|
NSLog("InCallManager: %@", message)
|
|
@@ -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.
|
|
3
|
+
"version": "1.38.0",
|
|
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.
|
|
54
|
-
"@stream-io/video-react-bindings": "1.16.3
|
|
53
|
+
"@stream-io/video-client": "1.53.0",
|
|
54
|
+
"@stream-io/video-react-bindings": "1.16.3",
|
|
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": "
|
|
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.
|
|
120
|
-
"@stream-io/react-native-callingx": "0.
|
|
121
|
-
"@stream-io/react-native-webrtc": "
|
|
122
|
-
"@stream-io/video-filters-react-native": "0.
|
|
119
|
+
"@stream-io/noise-cancellation-react-native": "^0.8.0",
|
|
120
|
+
"@stream-io/react-native-callingx": "^0.5.0",
|
|
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.
|
|
130
|
-
"expo-build-properties": "~56.0.
|
|
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.
|
|
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",
|
package/src/hooks/index.ts
CHANGED
|
@@ -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 {
|
|
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';
|