@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.
- package/CHANGELOG.md +33 -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/internal/registerSDKGlobals.js +16 -0
- package/dist/commonjs/utils/internal/registerSDKGlobals.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/internal/registerSDKGlobals.js +17 -1
- package/dist/module/utils/internal/registerSDKGlobals.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/internal/registerSDKGlobals.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/PictureInPictureAvatarView.swift +3 -1
- package/ios/PictureInPicture/StreamBufferTransformer.swift +13 -4
- package/ios/PictureInPicture/StreamPictureInPictureVideoRenderer.swift +79 -71
- package/ios/PictureInPicture/StreamRTCYUVBuffer.swift +20 -16
- package/ios/StreamInCallManager.swift +256 -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/internal/registerSDKGlobals.ts +23 -1
- 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,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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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.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.
|
|
54
|
-
"@stream-io/video-react-bindings": "1.16.
|
|
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": "
|
|
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.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.
|
|
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';
|