@stream-io/video-react-native-sdk 1.40.2 → 1.42.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 +48 -0
- package/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt +79 -54
- package/android/src/main/java/com/streamvideo/reactnative/audio/utils/AudioManagerUtil.kt +6 -2
- package/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt +3 -3
- package/dist/commonjs/components/Call/CallLayout/CallParticipantsSpotlight.js +2 -0
- package/dist/commonjs/components/Call/CallLayout/CallParticipantsSpotlight.js.map +1 -1
- package/dist/commonjs/components/Call/CallParticipantsList/CallParticipantsList.js +22 -31
- package/dist/commonjs/components/Call/CallParticipantsList/CallParticipantsList.js.map +1 -1
- package/dist/commonjs/components/Call/RingingCallContent/IncomingCall.js +8 -5
- package/dist/commonjs/components/Call/RingingCallContent/IncomingCall.js.map +1 -1
- package/dist/commonjs/components/Participant/ParticipantView/VideoRenderer/index.js +2 -1
- package/dist/commonjs/components/Participant/ParticipantView/VideoRenderer/index.js.map +1 -1
- package/dist/commonjs/hooks/index.js +11 -0
- package/dist/commonjs/hooks/index.js.map +1 -1
- package/dist/commonjs/hooks/push/useCallingExpWithCallingStateEffect.js +73 -0
- package/dist/commonjs/hooks/push/useCallingExpWithCallingStateEffect.js.map +1 -1
- package/dist/commonjs/hooks/useAudioDeviceStatus.js +76 -0
- package/dist/commonjs/hooks/useAudioDeviceStatus.js.map +1 -0
- package/dist/commonjs/modules/call-manager/CallManager.js +94 -47
- package/dist/commonjs/modules/call-manager/CallManager.js.map +1 -1
- package/dist/commonjs/utils/internal/callingx/callingx.js +0 -7
- package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
- package/dist/commonjs/utils/push/setupCallingExpEvents.js +31 -2
- package/dist/commonjs/utils/push/setupCallingExpEvents.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/module/components/Call/CallLayout/CallParticipantsSpotlight.js +3 -1
- package/dist/module/components/Call/CallLayout/CallParticipantsSpotlight.js.map +1 -1
- package/dist/module/components/Call/CallParticipantsList/CallParticipantsList.js +23 -32
- package/dist/module/components/Call/CallParticipantsList/CallParticipantsList.js.map +1 -1
- package/dist/module/components/Call/RingingCallContent/IncomingCall.js +9 -6
- package/dist/module/components/Call/RingingCallContent/IncomingCall.js.map +1 -1
- package/dist/module/components/Participant/ParticipantView/VideoRenderer/index.js +2 -1
- package/dist/module/components/Participant/ParticipantView/VideoRenderer/index.js.map +1 -1
- package/dist/module/hooks/index.js +1 -0
- package/dist/module/hooks/index.js.map +1 -1
- package/dist/module/hooks/push/useCallingExpWithCallingStateEffect.js +73 -0
- package/dist/module/hooks/push/useCallingExpWithCallingStateEffect.js.map +1 -1
- package/dist/module/hooks/useAudioDeviceStatus.js +71 -0
- package/dist/module/hooks/useAudioDeviceStatus.js.map +1 -0
- package/dist/module/modules/call-manager/CallManager.js +94 -47
- package/dist/module/modules/call-manager/CallManager.js.map +1 -1
- package/dist/module/utils/internal/callingx/callingx.js +0 -7
- package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
- package/dist/module/utils/push/setupCallingExpEvents.js +32 -3
- package/dist/module/utils/push/setupCallingExpEvents.js.map +1 -1
- package/dist/module/version.js +1 -1
- package/dist/typescript/components/Call/CallLayout/CallParticipantsSpotlight.d.ts.map +1 -1
- package/dist/typescript/components/Call/CallParticipantsList/CallParticipantsList.d.ts.map +1 -1
- package/dist/typescript/components/Call/RingingCallContent/IncomingCall.d.ts.map +1 -1
- package/dist/typescript/components/Participant/ParticipantView/VideoRenderer/index.d.ts.map +1 -1
- package/dist/typescript/hooks/index.d.ts +1 -0
- package/dist/typescript/hooks/index.d.ts.map +1 -1
- package/dist/typescript/hooks/push/useCallingExpWithCallingStateEffect.d.ts.map +1 -1
- package/dist/typescript/hooks/useAudioDeviceStatus.d.ts +21 -0
- package/dist/typescript/hooks/useAudioDeviceStatus.d.ts.map +1 -0
- package/dist/typescript/modules/call-manager/CallManager.d.ts +25 -11
- package/dist/typescript/modules/call-manager/CallManager.d.ts.map +1 -1
- package/dist/typescript/modules/call-manager/types.d.ts +29 -3
- package/dist/typescript/modules/call-manager/types.d.ts.map +1 -1
- package/dist/typescript/utils/internal/callingx/callingx.d.ts.map +1 -1
- package/dist/typescript/utils/push/setupCallingExpEvents.d.ts.map +1 -1
- package/dist/typescript/version.d.ts +1 -1
- package/expo-config-plugin/dist/index.js +2 -0
- package/expo-config-plugin/dist/withAndroidMessagingService.js +287 -0
- package/ios/StreamInCallManager.m +7 -0
- package/ios/StreamInCallManager.swift +257 -14
- package/package.json +8 -8
- package/src/components/Call/CallLayout/CallParticipantsSpotlight.tsx +6 -1
- package/src/components/Call/CallParticipantsList/CallParticipantsList.tsx +63 -62
- package/src/components/Call/RingingCallContent/IncomingCall.tsx +10 -12
- package/src/components/Participant/ParticipantView/VideoRenderer/index.tsx +4 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/push/useCallingExpWithCallingStateEffect.ts +95 -0
- package/src/hooks/useAudioDeviceStatus.ts +90 -0
- package/src/modules/call-manager/CallManager.ts +122 -64
- package/src/modules/call-manager/native-module.d.ts +14 -7
- package/src/modules/call-manager/types.ts +30 -3
- package/src/utils/internal/callingx/callingx.ts +0 -8
- package/src/utils/push/setupCallingExpEvents.ts +41 -3
- package/src/version.ts +1 -1
- package/dist/commonjs/utils/internal/callingx/audioSessionPromise.js +0 -68
- package/dist/commonjs/utils/internal/callingx/audioSessionPromise.js.map +0 -1
- package/dist/module/utils/internal/callingx/audioSessionPromise.js +0 -63
- package/dist/module/utils/internal/callingx/audioSessionPromise.js.map +0 -1
- package/dist/typescript/utils/internal/callingx/audioSessionPromise.d.ts +0 -16
- package/dist/typescript/utils/internal/callingx/audioSessionPromise.d.ts.map +0 -1
- package/src/utils/internal/callingx/audioSessionPromise.ts +0 -65
|
@@ -17,6 +17,20 @@ enum DefaultAudioDevice {
|
|
|
17
17
|
case earpiece
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/// An explicit output route chosen via the device picker.
|
|
21
|
+
///
|
|
22
|
+
/// `nil` (no explicit pick) means the call uses the category-driven default
|
|
23
|
+
/// (`.defaultToSpeaker` when the default device is the speaker), which keeps the
|
|
24
|
+
/// default route headphone-aware. Once the user picks a device we drive the route
|
|
25
|
+
/// explicitly instead — `.defaultToSpeaker` is dropped so `overrideOutputAudioPort(.none)`
|
|
26
|
+
/// can reach the earpiece (with `.defaultToSpeaker` present it resolves to the speaker).
|
|
27
|
+
private enum OutputRouting {
|
|
28
|
+
case speaker
|
|
29
|
+
/// Built-in earpiece (the built-in mic uid), wired headset, or Bluetooth — all
|
|
30
|
+
/// reached via `overrideOutputAudioPort(.none)` + `setPreferredInput(port)`.
|
|
31
|
+
case input(uid: String)
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
private enum Constants {
|
|
21
35
|
/// Debounce interval for refreshing stereo playout state after audio route changes.
|
|
22
36
|
///
|
|
@@ -32,6 +46,14 @@ private enum Constants {
|
|
|
32
46
|
|
|
33
47
|
private enum StreamInCallManagerEvents {
|
|
34
48
|
static let audioInterruption = "StreamInCallManagerAudioInterruption"
|
|
49
|
+
static let audioDeviceChanged = "onAudioDeviceChanged"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Stable device-id scheme for the JS layer. The built-in speaker is synthetic; every
|
|
53
|
+
/// other device is keyed by its `AVAudioSessionPortDescription.uid` (the
|
|
54
|
+
/// built-in earpiece is the built-in mic input routed with `.none`).
|
|
55
|
+
private enum AudioDeviceId {
|
|
56
|
+
static let speaker = "speaker"
|
|
35
57
|
}
|
|
36
58
|
|
|
37
59
|
@objc(StreamInCallManager)
|
|
@@ -42,6 +64,9 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
42
64
|
private var audioManagerActivated = false
|
|
43
65
|
private var callAudioRole: CallAudioRole = .communicator
|
|
44
66
|
private var defaultAudioDevice: DefaultAudioDevice = .speaker
|
|
67
|
+
/// The user's explicit output pick, or `nil` to use the category default.
|
|
68
|
+
/// Re-applied on every engine rebuild so the pick survives interruptions.
|
|
69
|
+
private var selectedOutput: OutputRouting?
|
|
45
70
|
private var enableStereo: Bool = false
|
|
46
71
|
private var previousVolume: Float = 0.75
|
|
47
72
|
|
|
@@ -101,7 +126,7 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
101
126
|
}
|
|
102
127
|
|
|
103
128
|
/// Builds the audio config for the current role/device and sets it as WebRTC's default.
|
|
104
|
-
private func makeAudioConfiguration() -> RTCAudioSessionConfiguration {
|
|
129
|
+
private func makeAudioConfiguration(for routing: OutputRouting?) -> RTCAudioSessionConfiguration {
|
|
105
130
|
let category: AVAudioSession.Category
|
|
106
131
|
let mode: AVAudioSession.Mode
|
|
107
132
|
let options: AVAudioSession.CategoryOptions
|
|
@@ -138,7 +163,14 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
138
163
|
#endif
|
|
139
164
|
category = .playAndRecord
|
|
140
165
|
mode = .voiceChat
|
|
141
|
-
|
|
166
|
+
|
|
167
|
+
var mustSetDefaultToSpeaker = defaultAudioDevice == .speaker
|
|
168
|
+
if case .input(let uid) = routing, isBuiltInMicUid(uid) {
|
|
169
|
+
// the selected output is a built-in mic
|
|
170
|
+
// then we always can't use defaultToSpeaker
|
|
171
|
+
mustSetDefaultToSpeaker = false
|
|
172
|
+
}
|
|
173
|
+
options = mustSetDefaultToSpeaker ? [bluetoothOption, .defaultToSpeaker] : [bluetoothOption]
|
|
142
174
|
}
|
|
143
175
|
|
|
144
176
|
let rtcConfig = RTCAudioSessionConfiguration.webRTC()
|
|
@@ -162,16 +194,21 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
162
194
|
func setup() {
|
|
163
195
|
audioSessionQueue.async { [self] in
|
|
164
196
|
let adm = getAudioDeviceModule()
|
|
197
|
+
selectedOutput = nil
|
|
198
|
+
|
|
199
|
+
// This path (no CallKit) owns audio-session activation itself, so the
|
|
200
|
+
// engine must be allowed to start. Restores availability in case a
|
|
201
|
+
// prior CallKit call had gated it off.
|
|
202
|
+
_ = adm.setEngineAvailability(.default)
|
|
165
203
|
|
|
166
204
|
// Stereo is listener-only and applies live. Cleared by stop()'s reset().
|
|
167
205
|
if callAudioRole == .listener && enableStereo {
|
|
168
206
|
adm.setStereoPlayoutPreference(true)
|
|
169
207
|
}
|
|
170
208
|
|
|
171
|
-
let rtcConfig = makeAudioConfiguration()
|
|
209
|
+
let rtcConfig = makeAudioConfiguration(for: selectedOutput)
|
|
172
210
|
log("Setup with category: \(rtcConfig.category), mode: \(rtcConfig.mode), options: \(rtcConfig.categoryOptions)")
|
|
173
211
|
|
|
174
|
-
// Set category only — the sink owns setActive().
|
|
175
212
|
let session = RTCAudioSession.sharedInstance()
|
|
176
213
|
session.lockForConfiguration()
|
|
177
214
|
defer { session.unlockForConfiguration() }
|
|
@@ -223,13 +260,16 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
223
260
|
return
|
|
224
261
|
}
|
|
225
262
|
|
|
226
|
-
let rtcConfig = makeAudioConfiguration()
|
|
263
|
+
let rtcConfig = makeAudioConfiguration(for: selectedOutput)
|
|
227
264
|
let session = RTCAudioSession.sharedInstance()
|
|
228
265
|
session.lockForConfiguration()
|
|
229
266
|
defer { session.unlockForConfiguration() }
|
|
230
267
|
do {
|
|
231
|
-
try session.setConfiguration(rtcConfig)
|
|
232
|
-
|
|
268
|
+
try session.setConfiguration(rtcConfig, active: true)
|
|
269
|
+
// Re-apply the explicit pick (if any) so it survives the engine rebuild.
|
|
270
|
+
if let selectedOutput {
|
|
271
|
+
try applyOutputRouting(selectedOutput)
|
|
272
|
+
}
|
|
233
273
|
log("engineWillEnable: applied category=\(rtcConfig.category) mode=\(rtcConfig.mode) activated=true")
|
|
234
274
|
} catch {
|
|
235
275
|
log("engineWillEnable error: \(String(describing: error))")
|
|
@@ -252,6 +292,21 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
252
292
|
}
|
|
253
293
|
}
|
|
254
294
|
|
|
295
|
+
private func clearOutputRouting() {
|
|
296
|
+
if Self.callingxOwnsSession() {
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
let session = RTCAudioSession.sharedInstance()
|
|
300
|
+
session.lockForConfiguration()
|
|
301
|
+
defer { session.unlockForConfiguration() }
|
|
302
|
+
do {
|
|
303
|
+
try session.overrideOutputAudioPort(.none)
|
|
304
|
+
try AVAudioSession.sharedInstance().setPreferredInput(nil)
|
|
305
|
+
} catch {
|
|
306
|
+
log("clearOutputRouting error: \(String(describing: error))")
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
255
310
|
/// Runtime KVC lookup of `CallingxSessionOwnership.callingxOwnsSession`.
|
|
256
311
|
/// `@stream-io/react-native-callingx` is an optional peer dep, so a direct
|
|
257
312
|
/// Swift `import` is not safe
|
|
@@ -303,6 +358,7 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
303
358
|
}
|
|
304
359
|
let adm = getAudioDeviceModule()
|
|
305
360
|
adm.reset()
|
|
361
|
+
clearOutputRouting()
|
|
306
362
|
// Deactivate directly: the .didDisableAudioEngine sink is async and we cancel it below.
|
|
307
363
|
applyConfigForEngineDisable()
|
|
308
364
|
// Tear down the engine-observer subscription so a re-setup wires a fresh one.
|
|
@@ -313,6 +369,7 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
313
369
|
stereoRefreshWorkItem = nil
|
|
314
370
|
callAudioRole = .communicator
|
|
315
371
|
defaultAudioDevice = .speaker
|
|
372
|
+
selectedOutput = nil
|
|
316
373
|
enableStereo = false
|
|
317
374
|
audioManagerActivated = false
|
|
318
375
|
}
|
|
@@ -345,18 +402,21 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
345
402
|
|
|
346
403
|
@objc(setForceSpeakerphoneOn:)
|
|
347
404
|
func setForceSpeakerphoneOn(enable: Bool) {
|
|
348
|
-
audioSessionQueue.async { [
|
|
405
|
+
audioSessionQueue.async { [self] in
|
|
349
406
|
let session = RTCAudioSession.sharedInstance()
|
|
350
407
|
session.lockForConfiguration()
|
|
351
408
|
defer {
|
|
352
409
|
session.unlockForConfiguration()
|
|
353
410
|
}
|
|
354
|
-
let avAudioSession = AVAudioSession.sharedInstance()
|
|
355
411
|
do {
|
|
356
|
-
|
|
357
|
-
|
|
412
|
+
if enable {
|
|
413
|
+
try applyOutputRouting(.speaker)
|
|
414
|
+
} else {
|
|
415
|
+
try session.overrideOutputAudioPort(.none)
|
|
416
|
+
try AVAudioSession.sharedInstance().setPreferredInput(nil)
|
|
417
|
+
}
|
|
358
418
|
} catch {
|
|
359
|
-
self
|
|
419
|
+
self.log("Error setting speakerphone: \(error)")
|
|
360
420
|
}
|
|
361
421
|
}
|
|
362
422
|
}
|
|
@@ -367,6 +427,174 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
367
427
|
log("iOS does not support setMicrophoneMute()")
|
|
368
428
|
}
|
|
369
429
|
|
|
430
|
+
// MARK: - Audio Device Picker
|
|
431
|
+
|
|
432
|
+
@objc(getAudioDeviceStatus:reject:)
|
|
433
|
+
func getAudioDeviceStatus(resolve: @escaping RCTPromiseResolveBlock,
|
|
434
|
+
reject: @escaping RCTPromiseRejectBlock) {
|
|
435
|
+
audioSessionQueue.async { [weak self] in
|
|
436
|
+
guard let self else {
|
|
437
|
+
resolve(["devices": [], "currentEndpointType": "Unknown"])
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
resolve(self.buildAudioDevicesState())
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/// Switches the audio output to the device with the given id.
|
|
445
|
+
@objc(chooseAudioDeviceEndpoint:)
|
|
446
|
+
func chooseAudioDeviceEndpoint(id: String) {
|
|
447
|
+
audioSessionQueue.async { [self] in
|
|
448
|
+
guard callAudioRole == .communicator else {
|
|
449
|
+
log("chooseAudioDeviceEndpoint ignored: only supported in communicator role")
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
let routing: OutputRouting = id == AudioDeviceId.speaker ? .speaker : .input(uid: id)
|
|
454
|
+
|
|
455
|
+
let session = RTCAudioSession.sharedInstance()
|
|
456
|
+
session.lockForConfiguration()
|
|
457
|
+
defer { session.unlockForConfiguration() }
|
|
458
|
+
do {
|
|
459
|
+
// Reconfigure first so the live category matches the pick (e.g. the earpiece
|
|
460
|
+
// drops `.defaultToSpeaker`, otherwise overrideOutputAudioPort(.none) would
|
|
461
|
+
// resolve back to the speaker). Then drive the route.
|
|
462
|
+
try session.setConfiguration(makeAudioConfiguration(for: routing))
|
|
463
|
+
try applyOutputRouting(routing)
|
|
464
|
+
selectedOutput = routing
|
|
465
|
+
} catch {
|
|
466
|
+
// a failure leaves the previous route intact.
|
|
467
|
+
log("chooseAudioDeviceEndpoint error: \(String(describing: error))")
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/// Re-applies the current output pick (`selectedOutput`) — category untouched, no setActive.
|
|
473
|
+
/// Called on interruption-end (via callingx's interruption event, forwarded from JS) to
|
|
474
|
+
/// restore a Bluetooth/wired route
|
|
475
|
+
@objc(reapplyAudioRoute)
|
|
476
|
+
func reapplyAudioRoute() {
|
|
477
|
+
audioSessionQueue.async { [self] in
|
|
478
|
+
guard callAudioRole == .communicator, let routing = selectedOutput else { return }
|
|
479
|
+
let session = RTCAudioSession.sharedInstance()
|
|
480
|
+
session.lockForConfiguration()
|
|
481
|
+
defer { session.unlockForConfiguration() }
|
|
482
|
+
do {
|
|
483
|
+
try applyOutputRouting(routing)
|
|
484
|
+
} catch {
|
|
485
|
+
log("reapplyAudioRoute error: \(String(describing: error))")
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/// Whether `uid` is the built-in microphone (i.e. an earpiece pick, in our id scheme).
|
|
491
|
+
private func isBuiltInMicUid(_ uid: String) -> Bool {
|
|
492
|
+
AVAudioSession.sharedInstance().availableInputs?
|
|
493
|
+
.contains { $0.uid == uid && $0.portType == .builtInMic } ?? false
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/// Drives the audio route. Throws if a session command fails, so callers can gate
|
|
497
|
+
/// committed state (e.g. `selectedOutput`) on the route actually being applied.
|
|
498
|
+
private func applyOutputRouting(_ routing: OutputRouting) throws {
|
|
499
|
+
let rtcSession = RTCAudioSession.sharedInstance()
|
|
500
|
+
let availableInputs = AVAudioSession.sharedInstance().availableInputs
|
|
501
|
+
switch routing {
|
|
502
|
+
case .speaker:
|
|
503
|
+
if let builtInMic = availableInputs?.first(where: { $0.portType == .builtInMic }) {
|
|
504
|
+
try rtcSession.setPreferredInput(builtInMic)
|
|
505
|
+
}
|
|
506
|
+
try rtcSession.overrideOutputAudioPort(.speaker)
|
|
507
|
+
case .input(let uid):
|
|
508
|
+
try rtcSession.overrideOutputAudioPort(.none)
|
|
509
|
+
if let port = availableInputs?.first(where: { $0.uid == uid }) {
|
|
510
|
+
try rtcSession.setPreferredInput(port)
|
|
511
|
+
} else {
|
|
512
|
+
log("applyOutputRouting: no input found for id \(uid)")
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/// Canonical endpoint-type string shared with the JS layer.M ust match with the Android implementation.
|
|
518
|
+
private func endpointType(for portType: AVAudioSession.Port) -> String {
|
|
519
|
+
switch portType {
|
|
520
|
+
case .builtInSpeaker: return "Speaker"
|
|
521
|
+
case .builtInMic, .builtInReceiver: return "Earpiece"
|
|
522
|
+
case .headphones, .headsetMic: return "Wired Headset"
|
|
523
|
+
case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE, .carAudio: return "Bluetooth Device"
|
|
524
|
+
default: return "Unknown"
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private func hasExternalAudioDevice(_ session: AVAudioSession) -> Bool {
|
|
529
|
+
let external: Set<AVAudioSession.Port> = [
|
|
530
|
+
.headphones, .headsetMic, .bluetoothHFP, .bluetoothA2DP, .bluetoothLE, .carAudio,
|
|
531
|
+
]
|
|
532
|
+
if session.availableInputs?.contains(where: { external.contains($0.portType) }) == true {
|
|
533
|
+
return true
|
|
534
|
+
}
|
|
535
|
+
return session.currentRoute.outputs.contains { external.contains($0.portType) }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private func buildAudioDevicesState() -> [String: Any] {
|
|
539
|
+
let session = AVAudioSession.sharedInstance()
|
|
540
|
+
let inputs = session.availableInputs ?? []
|
|
541
|
+
let hasExternal = hasExternalAudioDevice(session)
|
|
542
|
+
|
|
543
|
+
var devices: [[String: String]] = [
|
|
544
|
+
["id": AudioDeviceId.speaker, "name": "Speaker", "type": "Speaker"]
|
|
545
|
+
]
|
|
546
|
+
for port in inputs {
|
|
547
|
+
let isBuiltInMic = port.portType == .builtInMic
|
|
548
|
+
// Hide the earpiece when a wired/BT device is present — it can't be routed to
|
|
549
|
+
// while one is connected, so listing it would be a dead, duplicate option.
|
|
550
|
+
if isBuiltInMic && hasExternal { continue }
|
|
551
|
+
devices.append([
|
|
552
|
+
"id": port.uid,
|
|
553
|
+
"name": isBuiltInMic ? "Earpiece" : port.portName,
|
|
554
|
+
"type": endpointType(for: port.portType),
|
|
555
|
+
])
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
let output = session.currentRoute.outputs.first
|
|
559
|
+
let currentEndpointType = output.map { endpointType(for: $0.portType) } ?? "Unknown"
|
|
560
|
+
|
|
561
|
+
// Output-only routes (e.g. wired headphones without a mic) never appear in
|
|
562
|
+
// availableInputs. Add the active output when no listed device already represents
|
|
563
|
+
// its endpoint type, so it stays visible and selectable in the picker.
|
|
564
|
+
if let output,
|
|
565
|
+
output.portType != .builtInSpeaker,
|
|
566
|
+
!devices.contains(where: { $0["type"] == currentEndpointType }) {
|
|
567
|
+
devices.append([
|
|
568
|
+
"id": output.uid,
|
|
569
|
+
"name": output.portName,
|
|
570
|
+
"type": currentEndpointType,
|
|
571
|
+
])
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let selectedDeviceId: String?
|
|
575
|
+
if output?.portType == .builtInSpeaker {
|
|
576
|
+
selectedDeviceId = AudioDeviceId.speaker
|
|
577
|
+
} else if let inputUid = session.currentRoute.inputs.first?.uid,
|
|
578
|
+
devices.contains(where: { $0["id"] == inputUid }) {
|
|
579
|
+
// The active input maps to a listed device (earpiece / wired / BT).
|
|
580
|
+
selectedDeviceId = inputUid
|
|
581
|
+
} else {
|
|
582
|
+
// Output is on a device whose active input differs from a listed one — e.g. BT is
|
|
583
|
+
// the output while the input stayed built-in mic. Match by the output's endpoint
|
|
584
|
+
// type so the picker highlights the actual output (not the earpiece).
|
|
585
|
+
selectedDeviceId = devices.first(where: { $0["type"] == currentEndpointType })?["id"]
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
var result: [String: Any] = [
|
|
589
|
+
"devices": devices,
|
|
590
|
+
"currentEndpointType": currentEndpointType,
|
|
591
|
+
]
|
|
592
|
+
if let selectedDeviceId {
|
|
593
|
+
result["selectedDeviceId"] = selectedDeviceId
|
|
594
|
+
}
|
|
595
|
+
return result
|
|
596
|
+
}
|
|
597
|
+
|
|
370
598
|
@objc
|
|
371
599
|
func logAudioState() {
|
|
372
600
|
log(getAudioStateLog())
|
|
@@ -580,7 +808,19 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
580
808
|
}
|
|
581
809
|
|
|
582
810
|
logAudioState()
|
|
583
|
-
|
|
811
|
+
|
|
812
|
+
// Notify JS of the updated device list / selection.
|
|
813
|
+
audioSessionQueue.async { [weak self] in
|
|
814
|
+
guard let self,
|
|
815
|
+
self.audioManagerActivated,
|
|
816
|
+
self.callAudioRole == .communicator,
|
|
817
|
+
!Self.callingxOwnsSession() else { return }
|
|
818
|
+
self.sendEvent(
|
|
819
|
+
withName: StreamInCallManagerEvents.audioDeviceChanged,
|
|
820
|
+
body: self.buildAudioDevicesState()
|
|
821
|
+
)
|
|
822
|
+
}
|
|
823
|
+
|
|
584
824
|
// Route changes can arrive on arbitrary queues; ensure UI-safe work on main
|
|
585
825
|
DispatchQueue.main.async { [weak self] in
|
|
586
826
|
self?.updateProximityMonitoring()
|
|
@@ -616,7 +856,10 @@ class StreamInCallManager: RCTEventEmitter {
|
|
|
616
856
|
// MARK: - RCTEventEmitter
|
|
617
857
|
|
|
618
858
|
override func supportedEvents() -> [String]! {
|
|
619
|
-
return [
|
|
859
|
+
return [
|
|
860
|
+
StreamInCallManagerEvents.audioInterruption,
|
|
861
|
+
StreamInCallManagerEvents.audioDeviceChanged,
|
|
862
|
+
]
|
|
620
863
|
}
|
|
621
864
|
|
|
622
865
|
// MARK: - Helper Methods
|
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.42.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/",
|
|
@@ -52,8 +52,8 @@
|
|
|
52
52
|
"!**/.*"
|
|
53
53
|
],
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@stream-io/video-client": "1.
|
|
56
|
-
"@stream-io/video-react-bindings": "1.
|
|
55
|
+
"@stream-io/video-client": "1.56.0",
|
|
56
|
+
"@stream-io/video-react-bindings": "1.19.1",
|
|
57
57
|
"intl-pluralrules": "2.0.1",
|
|
58
58
|
"react-native-url-polyfill": "^3.0.0",
|
|
59
59
|
"rxjs": "~7.8.2",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"@react-native-firebase/messaging": ">=17.5.0",
|
|
66
66
|
"@stream-io/noise-cancellation-react-native": ">=0.1.0",
|
|
67
67
|
"@stream-io/react-native-callingx": ">=0.1.0",
|
|
68
|
-
"@stream-io/react-native-webrtc": "^145.
|
|
68
|
+
"@stream-io/react-native-webrtc": "^145.1.1",
|
|
69
69
|
"@stream-io/video-filters-react-native": ">=0.1.0",
|
|
70
70
|
"expo": ">=47.0.0",
|
|
71
71
|
"expo-notifications": "*",
|
|
@@ -114,11 +114,11 @@
|
|
|
114
114
|
"@react-native-firebase/messaging": "^24.0.0",
|
|
115
115
|
"@react-native/babel-preset": "0.85.3",
|
|
116
116
|
"@react-native/metro-config": "0.85.3",
|
|
117
|
-
"@stream-io/noise-cancellation-react-native": "^0.9.
|
|
118
|
-
"@stream-io/react-native-callingx": "^0.
|
|
119
|
-
"@stream-io/react-native-webrtc": "145.
|
|
117
|
+
"@stream-io/noise-cancellation-react-native": "^0.9.5",
|
|
118
|
+
"@stream-io/react-native-callingx": "^0.8.1",
|
|
119
|
+
"@stream-io/react-native-webrtc": "145.1.1",
|
|
120
120
|
"@stream-io/typescript-config": "^0.0.0",
|
|
121
|
-
"@stream-io/video-filters-react-native": "^0.14.
|
|
121
|
+
"@stream-io/video-filters-react-native": "^0.14.5",
|
|
122
122
|
"@testing-library/jest-native": "^5.4.3",
|
|
123
123
|
"@testing-library/react-native": "13.3.3",
|
|
124
124
|
"@tsconfig/node18": "^18.2.6",
|
|
@@ -3,7 +3,10 @@ import {
|
|
|
3
3
|
hasScreenShare,
|
|
4
4
|
type StreamVideoParticipant,
|
|
5
5
|
} from '@stream-io/video-client';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
useCall,
|
|
8
|
+
useSpeakerLayoutSortPreset,
|
|
9
|
+
} from '@stream-io/video-react-bindings';
|
|
7
10
|
import { StyleSheet, View } from 'react-native';
|
|
8
11
|
import { debounceTime } from 'rxjs';
|
|
9
12
|
import { ComponentTestIds } from '../../../constants/TestIds';
|
|
@@ -70,6 +73,8 @@ export const CallParticipantsSpotlight = ({
|
|
|
70
73
|
.subscribe(setAllParticipants);
|
|
71
74
|
return () => sub.unsubscribe();
|
|
72
75
|
}, [call]);
|
|
76
|
+
const isOneOnOneCall = allParticipants.length === 2;
|
|
77
|
+
useSpeakerLayoutSortPreset(call, isOneOnOneCall);
|
|
73
78
|
const [participantInSpotlight, ...otherParticipants] = allParticipants;
|
|
74
79
|
const isScreenShareOnSpotlight =
|
|
75
80
|
participantInSpotlight && hasScreenShare(participantInSpotlight);
|
|
@@ -2,6 +2,7 @@ import React, {
|
|
|
2
2
|
useCallback,
|
|
3
3
|
useEffect,
|
|
4
4
|
useMemo,
|
|
5
|
+
useReducer,
|
|
5
6
|
useRef,
|
|
6
7
|
useState,
|
|
7
8
|
} from 'react';
|
|
@@ -17,7 +18,6 @@ import {
|
|
|
17
18
|
type StreamVideoParticipantPatches,
|
|
18
19
|
VisibilityState,
|
|
19
20
|
} from '@stream-io/video-client';
|
|
20
|
-
import { Subject, debounceTime } from 'rxjs';
|
|
21
21
|
import { useCall } from '@stream-io/video-react-bindings';
|
|
22
22
|
import { ComponentTestIds } from '../../../constants/TestIds';
|
|
23
23
|
import {
|
|
@@ -109,19 +109,15 @@ export const CallParticipantsList = ({
|
|
|
109
109
|
if (!viewableParticipantSessionIds.current) {
|
|
110
110
|
viewableParticipantSessionIds.current = new Set();
|
|
111
111
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const forceUpdate
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
});
|
|
122
|
-
return () => sub.unsubscribe();
|
|
123
|
-
}, [forceUpdate$]);
|
|
124
|
-
const forceUpdate = useCallback(() => forceUpdate$.next(), [forceUpdate$]);
|
|
112
|
+
// Debounced force re-render: coalesces rapid viewability changes into a single
|
|
113
|
+
// re-render 500ms after the last change (drives the FlatList `extraData` below).
|
|
114
|
+
const [forceUpdateValue, forceRender] = useReducer((v: number) => v + 1, 0);
|
|
115
|
+
const forceUpdateTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
116
|
+
const forceUpdate = useCallback(() => {
|
|
117
|
+
clearTimeout(forceUpdateTimeout.current);
|
|
118
|
+
forceUpdateTimeout.current = setTimeout(forceRender, 500);
|
|
119
|
+
}, []);
|
|
120
|
+
useEffect(() => () => clearTimeout(forceUpdateTimeout.current), []);
|
|
125
121
|
|
|
126
122
|
// we use a ref to store the active call object
|
|
127
123
|
// so that it can be used in the onViewableItemsChanged callback
|
|
@@ -131,59 +127,64 @@ export const CallParticipantsList = ({
|
|
|
131
127
|
// This is the function that gets called when the user scrolls the list of participants.
|
|
132
128
|
// It updates viewableParticipantSessionIds HashSet with the session IDs
|
|
133
129
|
// of the participants that are currently visible.
|
|
134
|
-
const onViewableItemsChanged =
|
|
135
|
-
FlatListProps['onViewableItemsChanged']
|
|
136
|
-
>(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
130
|
+
const onViewableItemsChanged = useCallback<
|
|
131
|
+
NonNullable<FlatListProps['onViewableItemsChanged']>
|
|
132
|
+
>(
|
|
133
|
+
({ viewableItems }) => {
|
|
134
|
+
const participantPatches: StreamVideoParticipantPatches = {};
|
|
135
|
+
let mustUpdate = false;
|
|
136
|
+
const newVisibleParticipantSessionIds = new Set<string>(
|
|
137
|
+
viewableItems.map((v) => v.key),
|
|
138
|
+
);
|
|
139
|
+
const oldVisibleParticipantSessionIds =
|
|
140
|
+
viewableParticipantSessionIds.current;
|
|
141
|
+
const visibilityPatch = (
|
|
142
|
+
videoTrack: VisibilityState,
|
|
143
|
+
): StreamVideoParticipantPatches[string] => ({
|
|
144
|
+
viewportVisibilityState: {
|
|
145
|
+
videoTrack,
|
|
146
|
+
screenShareTrack: VisibilityState.UNKNOWN,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
newVisibleParticipantSessionIds.forEach((key) => {
|
|
150
|
+
if (!oldVisibleParticipantSessionIds.has(key)) {
|
|
151
|
+
mustUpdate = true;
|
|
152
|
+
participantPatches[key] = visibilityPatch(VisibilityState.VISIBLE);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
oldVisibleParticipantSessionIds.forEach((key) => {
|
|
156
|
+
if (!newVisibleParticipantSessionIds.has(key)) {
|
|
157
|
+
mustUpdate = true;
|
|
158
|
+
participantPatches[key] = visibilityPatch(VisibilityState.INVISIBLE);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
viewableParticipantSessionIds.current = newVisibleParticipantSessionIds;
|
|
162
|
+
if (mustUpdate) {
|
|
163
|
+
activeCallRef.current?.state.updateParticipants(participantPatches);
|
|
164
|
+
forceUpdate();
|
|
164
165
|
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
activeCallRef.current?.state.updateParticipants(participantPatches);
|
|
169
|
-
forceUpdate();
|
|
170
|
-
}
|
|
171
|
-
}).current;
|
|
166
|
+
},
|
|
167
|
+
[forceUpdate],
|
|
168
|
+
);
|
|
172
169
|
|
|
173
170
|
// NOTE: key must be sessionId always as it is used to track viewable participants
|
|
174
|
-
const keyExtractor =
|
|
171
|
+
const keyExtractor = useCallback<NonNullable<FlatListProps['keyExtractor']>>(
|
|
175
172
|
(item) => item.sessionId,
|
|
176
|
-
|
|
173
|
+
[],
|
|
174
|
+
);
|
|
177
175
|
|
|
178
|
-
const onLayout =
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
176
|
+
const onLayout = useCallback<NonNullable<FlatListProps['onLayout']>>(
|
|
177
|
+
(event) => {
|
|
178
|
+
const { height, width } = event.nativeEvent.layout;
|
|
179
|
+
setContainerLayout((prev) => {
|
|
180
|
+
if (prev.height === height && prev.width === width) {
|
|
181
|
+
return prev;
|
|
182
|
+
}
|
|
183
|
+
return { height, width };
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
[],
|
|
187
|
+
);
|
|
187
188
|
|
|
188
189
|
const { itemHeight, itemWidth } = calculateParticipantViewSize({
|
|
189
190
|
containerHeight: containerLayout.height,
|
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import {
|
|
3
|
-
ImageBackground,
|
|
4
|
-
StyleSheet,
|
|
5
|
-
Text,
|
|
6
|
-
View,
|
|
7
|
-
type ViewStyle,
|
|
8
|
-
} from 'react-native';
|
|
2
|
+
import { Image, StyleSheet, Text, View, type ViewStyle } from 'react-native';
|
|
9
3
|
import {
|
|
10
4
|
useCallStateHooks,
|
|
11
5
|
useConnectedUser,
|
|
@@ -120,18 +114,22 @@ const Background: React.FunctionComponent<{
|
|
|
120
114
|
|
|
121
115
|
if (avatarsToShow.length) {
|
|
122
116
|
return (
|
|
123
|
-
<
|
|
124
|
-
source={{
|
|
125
|
-
uri: avatarsToShow[0],
|
|
126
|
-
}}
|
|
117
|
+
<View
|
|
127
118
|
style={[
|
|
128
119
|
styles.background,
|
|
129
120
|
{ backgroundColor: colors.sheetTertiary },
|
|
130
121
|
incomingCall.background,
|
|
131
122
|
]}
|
|
132
123
|
>
|
|
124
|
+
<Image
|
|
125
|
+
source={{
|
|
126
|
+
uri: avatarsToShow[0],
|
|
127
|
+
}}
|
|
128
|
+
resizeMode="cover"
|
|
129
|
+
style={StyleSheet.absoluteFill}
|
|
130
|
+
/>
|
|
133
131
|
{children}
|
|
134
|
-
</
|
|
132
|
+
</View>
|
|
135
133
|
);
|
|
136
134
|
}
|
|
137
135
|
return (
|
|
@@ -90,8 +90,11 @@ export const VideoRenderer = React.memo(
|
|
|
90
90
|
? screenShareStream
|
|
91
91
|
: videoStream) as unknown as MediaStream | undefined;
|
|
92
92
|
|
|
93
|
+
const hasVideoTrackInStream =
|
|
94
|
+
!!videoStreamToRender && videoStreamToRender.getVideoTracks().length > 0;
|
|
95
|
+
|
|
93
96
|
const canShowVideo =
|
|
94
|
-
|
|
97
|
+
hasVideoTrackInStream &&
|
|
95
98
|
isVisible &&
|
|
96
99
|
isPublishingVideoTrack &&
|
|
97
100
|
!hasPausedTrack(participant, trackType) &&
|
package/src/hooks/index.ts
CHANGED