@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/android/src/main/java/com/streamvideo/reactnative/audio/AudioDeviceManager.kt +79 -54
  3. package/android/src/main/java/com/streamvideo/reactnative/audio/utils/AudioManagerUtil.kt +6 -2
  4. package/android/src/main/java/com/streamvideo/reactnative/callmanager/StreamInCallManagerModule.kt +3 -3
  5. package/dist/commonjs/components/Call/CallLayout/CallParticipantsSpotlight.js +2 -0
  6. package/dist/commonjs/components/Call/CallLayout/CallParticipantsSpotlight.js.map +1 -1
  7. package/dist/commonjs/components/Call/CallParticipantsList/CallParticipantsList.js +22 -31
  8. package/dist/commonjs/components/Call/CallParticipantsList/CallParticipantsList.js.map +1 -1
  9. package/dist/commonjs/components/Call/RingingCallContent/IncomingCall.js +8 -5
  10. package/dist/commonjs/components/Call/RingingCallContent/IncomingCall.js.map +1 -1
  11. package/dist/commonjs/components/Participant/ParticipantView/VideoRenderer/index.js +2 -1
  12. package/dist/commonjs/components/Participant/ParticipantView/VideoRenderer/index.js.map +1 -1
  13. package/dist/commonjs/hooks/index.js +11 -0
  14. package/dist/commonjs/hooks/index.js.map +1 -1
  15. package/dist/commonjs/hooks/push/useCallingExpWithCallingStateEffect.js +73 -0
  16. package/dist/commonjs/hooks/push/useCallingExpWithCallingStateEffect.js.map +1 -1
  17. package/dist/commonjs/hooks/useAudioDeviceStatus.js +76 -0
  18. package/dist/commonjs/hooks/useAudioDeviceStatus.js.map +1 -0
  19. package/dist/commonjs/modules/call-manager/CallManager.js +94 -47
  20. package/dist/commonjs/modules/call-manager/CallManager.js.map +1 -1
  21. package/dist/commonjs/utils/internal/callingx/callingx.js +0 -7
  22. package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
  23. package/dist/commonjs/utils/push/setupCallingExpEvents.js +31 -2
  24. package/dist/commonjs/utils/push/setupCallingExpEvents.js.map +1 -1
  25. package/dist/commonjs/version.js +1 -1
  26. package/dist/module/components/Call/CallLayout/CallParticipantsSpotlight.js +3 -1
  27. package/dist/module/components/Call/CallLayout/CallParticipantsSpotlight.js.map +1 -1
  28. package/dist/module/components/Call/CallParticipantsList/CallParticipantsList.js +23 -32
  29. package/dist/module/components/Call/CallParticipantsList/CallParticipantsList.js.map +1 -1
  30. package/dist/module/components/Call/RingingCallContent/IncomingCall.js +9 -6
  31. package/dist/module/components/Call/RingingCallContent/IncomingCall.js.map +1 -1
  32. package/dist/module/components/Participant/ParticipantView/VideoRenderer/index.js +2 -1
  33. package/dist/module/components/Participant/ParticipantView/VideoRenderer/index.js.map +1 -1
  34. package/dist/module/hooks/index.js +1 -0
  35. package/dist/module/hooks/index.js.map +1 -1
  36. package/dist/module/hooks/push/useCallingExpWithCallingStateEffect.js +73 -0
  37. package/dist/module/hooks/push/useCallingExpWithCallingStateEffect.js.map +1 -1
  38. package/dist/module/hooks/useAudioDeviceStatus.js +71 -0
  39. package/dist/module/hooks/useAudioDeviceStatus.js.map +1 -0
  40. package/dist/module/modules/call-manager/CallManager.js +94 -47
  41. package/dist/module/modules/call-manager/CallManager.js.map +1 -1
  42. package/dist/module/utils/internal/callingx/callingx.js +0 -7
  43. package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
  44. package/dist/module/utils/push/setupCallingExpEvents.js +32 -3
  45. package/dist/module/utils/push/setupCallingExpEvents.js.map +1 -1
  46. package/dist/module/version.js +1 -1
  47. package/dist/typescript/components/Call/CallLayout/CallParticipantsSpotlight.d.ts.map +1 -1
  48. package/dist/typescript/components/Call/CallParticipantsList/CallParticipantsList.d.ts.map +1 -1
  49. package/dist/typescript/components/Call/RingingCallContent/IncomingCall.d.ts.map +1 -1
  50. package/dist/typescript/components/Participant/ParticipantView/VideoRenderer/index.d.ts.map +1 -1
  51. package/dist/typescript/hooks/index.d.ts +1 -0
  52. package/dist/typescript/hooks/index.d.ts.map +1 -1
  53. package/dist/typescript/hooks/push/useCallingExpWithCallingStateEffect.d.ts.map +1 -1
  54. package/dist/typescript/hooks/useAudioDeviceStatus.d.ts +21 -0
  55. package/dist/typescript/hooks/useAudioDeviceStatus.d.ts.map +1 -0
  56. package/dist/typescript/modules/call-manager/CallManager.d.ts +25 -11
  57. package/dist/typescript/modules/call-manager/CallManager.d.ts.map +1 -1
  58. package/dist/typescript/modules/call-manager/types.d.ts +29 -3
  59. package/dist/typescript/modules/call-manager/types.d.ts.map +1 -1
  60. package/dist/typescript/utils/internal/callingx/callingx.d.ts.map +1 -1
  61. package/dist/typescript/utils/push/setupCallingExpEvents.d.ts.map +1 -1
  62. package/dist/typescript/version.d.ts +1 -1
  63. package/expo-config-plugin/dist/index.js +2 -0
  64. package/expo-config-plugin/dist/withAndroidMessagingService.js +287 -0
  65. package/ios/StreamInCallManager.m +7 -0
  66. package/ios/StreamInCallManager.swift +257 -14
  67. package/package.json +8 -8
  68. package/src/components/Call/CallLayout/CallParticipantsSpotlight.tsx +6 -1
  69. package/src/components/Call/CallParticipantsList/CallParticipantsList.tsx +63 -62
  70. package/src/components/Call/RingingCallContent/IncomingCall.tsx +10 -12
  71. package/src/components/Participant/ParticipantView/VideoRenderer/index.tsx +4 -1
  72. package/src/hooks/index.ts +1 -0
  73. package/src/hooks/push/useCallingExpWithCallingStateEffect.ts +95 -0
  74. package/src/hooks/useAudioDeviceStatus.ts +90 -0
  75. package/src/modules/call-manager/CallManager.ts +122 -64
  76. package/src/modules/call-manager/native-module.d.ts +14 -7
  77. package/src/modules/call-manager/types.ts +30 -3
  78. package/src/utils/internal/callingx/callingx.ts +0 -8
  79. package/src/utils/push/setupCallingExpEvents.ts +41 -3
  80. package/src/version.ts +1 -1
  81. package/dist/commonjs/utils/internal/callingx/audioSessionPromise.js +0 -68
  82. package/dist/commonjs/utils/internal/callingx/audioSessionPromise.js.map +0 -1
  83. package/dist/module/utils/internal/callingx/audioSessionPromise.js +0 -63
  84. package/dist/module/utils/internal/callingx/audioSessionPromise.js.map +0 -1
  85. package/dist/typescript/utils/internal/callingx/audioSessionPromise.d.ts +0 -16
  86. package/dist/typescript/utils/internal/callingx/audioSessionPromise.d.ts.map +0 -1
  87. 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
- options = defaultAudioDevice == .speaker ? [bluetoothOption, .defaultToSpeaker] : [bluetoothOption]
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
- try session.setActive(true)
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 { [weak self] in
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
- try avAudioSession.overrideOutputAudioPort(enable ? .speaker : .none)
357
- try avAudioSession.setActive(true)
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?.log("Error setting speakerphone: \(error)")
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 [StreamInCallManagerEvents.audioInterruption]
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.40.2",
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.55.1",
56
- "@stream-io/video-react-bindings": "1.18.2",
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.0.0",
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.2",
118
- "@stream-io/react-native-callingx": "^0.6.2",
119
- "@stream-io/react-native-webrtc": "145.0.0",
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.2",
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 { useCall } from '@stream-io/video-react-bindings';
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
- const forceUpdate$Ref = useRef<Subject<void>>(null!);
113
- if (!forceUpdate$Ref.current) {
114
- forceUpdate$Ref.current = new Subject<void>();
115
- }
116
- const forceUpdate$ = forceUpdate$Ref.current;
117
- const [forceUpdateValue, setForceUpdateValue] = useState(0);
118
- useEffect(() => {
119
- const sub = forceUpdate$.pipe(debounceTime(500)).subscribe(() => {
120
- setForceUpdateValue((v) => v + 1);
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 = useRef<
135
- FlatListProps['onViewableItemsChanged']
136
- >(({ viewableItems }) => {
137
- const participantPatches: StreamVideoParticipantPatches = {};
138
- let mustUpdate = false;
139
- const newVisibleParticipantSessionIds = new Set<string>(
140
- viewableItems.map((v) => v.key),
141
- );
142
- const oldVisibleParticipantSessionIds =
143
- viewableParticipantSessionIds.current;
144
- newVisibleParticipantSessionIds.forEach((key) => {
145
- if (!oldVisibleParticipantSessionIds.has(key)) {
146
- mustUpdate = true;
147
- participantPatches[key] = {
148
- viewportVisibilityState: {
149
- videoTrack: VisibilityState.VISIBLE,
150
- screenShareTrack: VisibilityState.UNKNOWN,
151
- },
152
- };
153
- }
154
- });
155
- oldVisibleParticipantSessionIds.forEach((key) => {
156
- if (!newVisibleParticipantSessionIds.has(key)) {
157
- mustUpdate = true;
158
- participantPatches[key] = {
159
- viewportVisibilityState: {
160
- videoTrack: VisibilityState.VISIBLE,
161
- screenShareTrack: VisibilityState.UNKNOWN,
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
- viewableParticipantSessionIds.current = newVisibleParticipantSessionIds;
167
- if (mustUpdate) {
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 = useRef<NonNullable<FlatListProps['keyExtractor']>>(
171
+ const keyExtractor = useCallback<NonNullable<FlatListProps['keyExtractor']>>(
175
172
  (item) => item.sessionId,
176
- ).current;
173
+ [],
174
+ );
177
175
 
178
- const onLayout = useRef<NonNullable<FlatListProps['onLayout']>>((event) => {
179
- const { height, width } = event.nativeEvent.layout;
180
- setContainerLayout((prev) => {
181
- if (prev.height === height && prev.width === width) {
182
- return prev;
183
- }
184
- return { height, width };
185
- });
186
- }).current;
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
- <ImageBackground
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
- </ImageBackground>
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
- !!videoStreamToRender &&
97
+ hasVideoTrackInStream &&
95
98
  isVisible &&
96
99
  isPublishingVideoTrack &&
97
100
  !hasPausedTrack(participant, trackType) &&
@@ -11,3 +11,4 @@ export * from './useLoopbackRecording';
11
11
  export * from './useTrackDimensions';
12
12
  export * from './useScreenshot';
13
13
  export * from './useModeration';
14
+ export * from './useAudioDeviceStatus';