@stream-io/video-react-native-sdk 1.41.0 → 1.43.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 (98) hide show
  1. package/CHANGELOG.md +40 -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/RingingCallContent/OutgoingCall.js.map +1 -1
  6. package/dist/commonjs/components/Participant/FloatingParticipantView/index.js.map +1 -1
  7. package/dist/commonjs/contexts/ThemeContext.js +10 -0
  8. package/dist/commonjs/contexts/ThemeContext.js.map +1 -1
  9. package/dist/commonjs/hooks/index.js +11 -0
  10. package/dist/commonjs/hooks/index.js.map +1 -1
  11. package/dist/commonjs/hooks/push/useCallingExpWithCallingStateEffect.js +73 -0
  12. package/dist/commonjs/hooks/push/useCallingExpWithCallingStateEffect.js.map +1 -1
  13. package/dist/commonjs/hooks/useAudioDeviceStatus.js +76 -0
  14. package/dist/commonjs/hooks/useAudioDeviceStatus.js.map +1 -0
  15. package/dist/commonjs/hooks/useScreenShareButton.js.map +1 -1
  16. package/dist/commonjs/modules/call-manager/CallManager.js +94 -47
  17. package/dist/commonjs/modules/call-manager/CallManager.js.map +1 -1
  18. package/dist/commonjs/providers/NoiseCancellation/lib.js.map +1 -1
  19. package/dist/commonjs/providers/StreamCall/AudioInterruptionTracer.js.map +1 -1
  20. package/dist/commonjs/utils/internal/callingx/callingx.js +0 -7
  21. package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
  22. package/dist/commonjs/utils/push/setupCallingExpEvents.js +0 -2
  23. package/dist/commonjs/utils/push/setupCallingExpEvents.js.map +1 -1
  24. package/dist/commonjs/version.js +1 -1
  25. package/dist/module/components/Call/RingingCallContent/OutgoingCall.js.map +1 -1
  26. package/dist/module/components/Participant/FloatingParticipantView/index.js.map +1 -1
  27. package/dist/module/contexts/ThemeContext.js +10 -0
  28. package/dist/module/contexts/ThemeContext.js.map +1 -1
  29. package/dist/module/hooks/index.js +1 -0
  30. package/dist/module/hooks/index.js.map +1 -1
  31. package/dist/module/hooks/push/useCallingExpWithCallingStateEffect.js +73 -0
  32. package/dist/module/hooks/push/useCallingExpWithCallingStateEffect.js.map +1 -1
  33. package/dist/module/hooks/useAudioDeviceStatus.js +71 -0
  34. package/dist/module/hooks/useAudioDeviceStatus.js.map +1 -0
  35. package/dist/module/hooks/useScreenShareButton.js.map +1 -1
  36. package/dist/module/modules/call-manager/CallManager.js +94 -47
  37. package/dist/module/modules/call-manager/CallManager.js.map +1 -1
  38. package/dist/module/providers/NoiseCancellation/lib.js.map +1 -1
  39. package/dist/module/providers/StreamCall/AudioInterruptionTracer.js.map +1 -1
  40. package/dist/module/utils/internal/callingx/callingx.js +0 -7
  41. package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
  42. package/dist/module/utils/push/setupCallingExpEvents.js +0 -2
  43. package/dist/module/utils/push/setupCallingExpEvents.js.map +1 -1
  44. package/dist/module/version.js +1 -1
  45. package/dist/typescript/components/Participant/FloatingParticipantView/index.d.ts.map +1 -1
  46. package/dist/typescript/contexts/ThemeContext.d.ts +10 -1
  47. package/dist/typescript/contexts/ThemeContext.d.ts.map +1 -1
  48. package/dist/typescript/hooks/index.d.ts +1 -0
  49. package/dist/typescript/hooks/index.d.ts.map +1 -1
  50. package/dist/typescript/hooks/push/useCallingExpWithCallingStateEffect.d.ts.map +1 -1
  51. package/dist/typescript/hooks/useAudioDeviceStatus.d.ts +21 -0
  52. package/dist/typescript/hooks/useAudioDeviceStatus.d.ts.map +1 -0
  53. package/dist/typescript/hooks/useScreenShareButton.d.ts +2 -1
  54. package/dist/typescript/hooks/useScreenShareButton.d.ts.map +1 -1
  55. package/dist/typescript/modules/call-manager/CallManager.d.ts +25 -11
  56. package/dist/typescript/modules/call-manager/CallManager.d.ts.map +1 -1
  57. package/dist/typescript/modules/call-manager/types.d.ts +29 -3
  58. package/dist/typescript/modules/call-manager/types.d.ts.map +1 -1
  59. package/dist/typescript/providers/NoiseCancellation/lib.d.ts.map +1 -1
  60. package/dist/typescript/providers/StreamCall/AudioInterruptionTracer.d.ts.map +1 -1
  61. package/dist/typescript/utils/StreamVideoRN/types.d.ts.map +1 -1
  62. package/dist/typescript/utils/internal/callingx/callingx.d.ts.map +1 -1
  63. package/dist/typescript/utils/push/setupCallingExpEvents.d.ts.map +1 -1
  64. package/dist/typescript/version.d.ts +1 -1
  65. package/expo-config-plugin/dist/index.js +2 -0
  66. package/expo-config-plugin/dist/withAndroidMessagingService.js +313 -0
  67. package/expo-config-plugin/dist/withIosScreenCapture/addBroadcastExtensionXcodeTarget.js +84 -2
  68. package/expo-config-plugin/dist/withIosScreenCapture/withPlistUpdates.js +3 -6
  69. package/expo-config-plugin/dist/withIosScreenCapture/withTarget.js +6 -4
  70. package/expo-config-plugin/static/AudioUploader.swift +230 -0
  71. package/expo-config-plugin/static/SampleHandler.swift +67 -14
  72. package/ios/StreamInCallManager.m +7 -0
  73. package/ios/StreamInCallManager.swift +258 -15
  74. package/ios/StreamVideoReactNative.m +15 -5
  75. package/package.json +27 -25
  76. package/src/components/Call/RingingCallContent/OutgoingCall.tsx +1 -2
  77. package/src/components/Participant/FloatingParticipantView/index.tsx +1 -4
  78. package/src/contexts/ThemeContext.tsx +10 -1
  79. package/src/hooks/index.ts +1 -0
  80. package/src/hooks/push/useCallingExpWithCallingStateEffect.ts +95 -0
  81. package/src/hooks/useAudioDeviceStatus.ts +90 -0
  82. package/src/hooks/useScreenShareButton.ts +2 -1
  83. package/src/modules/call-manager/CallManager.ts +123 -66
  84. package/src/modules/call-manager/native-module.d.ts +14 -7
  85. package/src/modules/call-manager/types.ts +30 -3
  86. package/src/providers/NoiseCancellation/lib.ts +1 -2
  87. package/src/providers/StreamCall/AudioInterruptionTracer.tsx +1 -2
  88. package/src/utils/StreamVideoRN/types.ts +1 -3
  89. package/src/utils/internal/callingx/callingx.ts +0 -8
  90. package/src/utils/push/setupCallingExpEvents.ts +0 -2
  91. package/src/version.ts +1 -1
  92. package/dist/commonjs/utils/internal/callingx/audioSessionPromise.js +0 -68
  93. package/dist/commonjs/utils/internal/callingx/audioSessionPromise.js.map +0 -1
  94. package/dist/module/utils/internal/callingx/audioSessionPromise.js +0 -63
  95. package/dist/module/utils/internal/callingx/audioSessionPromise.js.map +0 -1
  96. package/dist/typescript/utils/internal/callingx/audioSessionPromise.d.ts +0 -16
  97. package/dist/typescript/utils/internal/callingx/audioSessionPromise.d.ts.map +0 -1
  98. package/src/utils/internal/callingx/audioSessionPromise.ts +0 -65
@@ -1,6 +1,8 @@
1
1
  import { Call, CallingState, videoLoggerSystem } from '@stream-io/video-client';
2
2
  import { useCall, useCallStateHooks } from '@stream-io/video-react-bindings';
3
3
  import { useEffect, useMemo } from 'react';
4
+ import { Platform } from 'react-native';
5
+ import { AudioDeviceModule } from '@stream-io/react-native-webrtc';
4
6
  import { filter, take } from 'rxjs/operators';
5
7
  import { getCallDisplayName } from '../../utils/internal/callingx/callingx';
6
8
  import { getCallingxLibIfAvailable } from '../../utils/push/libs/callingx';
@@ -190,4 +192,97 @@ export const useCallingExpWithCallingStateEffect = () => {
190
192
  subscription.remove();
191
193
  };
192
194
  }, [activeCallCid, microphone]);
195
+
196
+ // Trace CallKit audio session activation/deactivation onto the call's tracer.
197
+ useEffect(() => {
198
+ const callingx = getCallingxLibIfAvailable();
199
+ if (!callingx?.isSetup || !activeCall) {
200
+ return;
201
+ }
202
+
203
+ const activateSubscription = callingx.addEventListener(
204
+ 'didActivateAudioSession',
205
+ () => {
206
+ activeCall.tracer.trace('callingx.didActivateAudioSession', null);
207
+ },
208
+ );
209
+ const deactivateSubscription = callingx.addEventListener(
210
+ 'didDeactivateAudioSession',
211
+ () => {
212
+ activeCall.tracer.trace('callingx.didDeactivateAudioSession', null);
213
+ },
214
+ );
215
+
216
+ return () => {
217
+ activateSubscription.remove();
218
+ deactivateSubscription.remove();
219
+ };
220
+ }, [activeCall]);
221
+
222
+ // Trace the iOS ADM recording state to detect silent-mic calls: the engine's
223
+ // input bring-up racing the CallKit audio-session activation could leave
224
+ // recording silently never started (mic track live, zero audio RTP sent).
225
+ // Samples shortly after JOINED and after every didActivateAudioSession, and
226
+ // traces healthy calls too so the failure rate is measurable. Detection only;
227
+ // the engine-availability gate in callingx owns prevention.
228
+ useEffect(() => {
229
+ const callingx = getCallingxLibIfAvailable();
230
+ if (Platform.OS !== 'ios' || !callingx?.isSetup || !activeCall) {
231
+ return;
232
+ }
233
+
234
+ const timeouts = new Set<ReturnType<typeof setTimeout>>();
235
+
236
+ const traceRecordingState = (trigger: string) => {
237
+ if (activeCall.state.callingState !== CallingState.JOINED) return;
238
+ if (activeCall.microphone.state.status !== 'enabled') return;
239
+
240
+ let state;
241
+ try {
242
+ state = {
243
+ trigger,
244
+ isRecording: AudioDeviceModule.isRecording(),
245
+ isEngineRunning: AudioDeviceModule.isEngineRunning(),
246
+ isMicrophoneMuted: AudioDeviceModule.isMicrophoneMuted(),
247
+ };
248
+ } catch (error: unknown) {
249
+ logger.warn('failed to read the ADM recording state', error);
250
+ return;
251
+ }
252
+
253
+ activeCall.tracer.trace('ios.audioRecording.state', state);
254
+ if (!state.isRecording) {
255
+ logger.warn('mic is enabled but ADM is not recording', state);
256
+ }
257
+ };
258
+
259
+ const scheduleCheck = (trigger: string, delayMs: number) => {
260
+ const timeout = setTimeout(() => {
261
+ timeouts.delete(timeout);
262
+ traceRecordingState(trigger);
263
+ }, delayMs);
264
+ timeouts.add(timeout);
265
+ };
266
+
267
+ const joinedSubscription = activeCall.state.callingState$
268
+ .pipe(
269
+ filter((callingState) => callingState === CallingState.JOINED),
270
+ take(1),
271
+ )
272
+ // delayed so the normal engine bring-up can complete on its own
273
+ .subscribe(() => scheduleCheck('joined', 3000));
274
+
275
+ const activationSubscription = callingx.addEventListener(
276
+ 'didActivateAudioSession',
277
+ // delayed so the activation-driven engine start can run first
278
+ () => scheduleCheck('audioSessionActivated', 1000),
279
+ );
280
+
281
+ return () => {
282
+ joinedSubscription.unsubscribe();
283
+ activationSubscription.remove();
284
+ timeouts.forEach(clearTimeout);
285
+ timeouts.clear();
286
+ };
287
+ }, [activeCall]);
193
288
  };
@@ -0,0 +1,90 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { callManager } from '../modules/call-manager';
3
+ import type { AudioDevicesState } from '../modules/call-manager/types';
4
+
5
+ /**
6
+ * Structural equality for two audio device states to avoid redundant re-renders.
7
+ */
8
+ const areStatesEqual = (
9
+ a: AudioDevicesState | undefined,
10
+ b: AudioDevicesState,
11
+ ): boolean => {
12
+ if (!a) return false;
13
+ if (
14
+ a.selectedDeviceId !== b.selectedDeviceId ||
15
+ a.currentEndpointType !== b.currentEndpointType ||
16
+ a.devices.length !== b.devices.length
17
+ ) {
18
+ return false;
19
+ }
20
+ return a.devices.every((device, i) => {
21
+ const other = b.devices[i];
22
+ return (
23
+ other !== undefined &&
24
+ device.id === other.id &&
25
+ device.name === other.name &&
26
+ device.type === other.type
27
+ );
28
+ });
29
+ };
30
+
31
+ /**
32
+ * Subscribes to the current audio output device state and keeps it in sync.
33
+ *
34
+ * Works on Android, iOS, and iOS with CallKit
35
+ * (`@stream-io/react-native-callingx`) — the underlying source is selected
36
+ * automatically. Pair with `callManager.audioDevices.select(device.id)` to
37
+ * build a custom audio route picker.
38
+ *
39
+ * @returns the latest {@link AudioDevicesState}, or `undefined` until the
40
+ * initial status resolves.
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * const status = useAudioDeviceStatus();
45
+ * const { devices, selectedDeviceId } = status ?? {};
46
+ * // callManager.audioDevices.select(devices[0].id);
47
+ * ```
48
+ */
49
+ export const useAudioDeviceStatus = (): AudioDevicesState | undefined => {
50
+ const [state, setState] = useState<AudioDevicesState>();
51
+
52
+ useEffect(() => {
53
+ let active = true;
54
+ let receivedUpdate = false;
55
+ let unsubscribe = () => {};
56
+
57
+ const applyIfChanged = (next: AudioDevicesState) => {
58
+ setState((prev) => (areStatesEqual(prev, next) ? prev : next));
59
+ };
60
+
61
+ // Live change events take precedence over the initial fetch.
62
+ const onChange = (next: AudioDevicesState) => {
63
+ if (!active) return;
64
+ receivedUpdate = true;
65
+ applyIfChanged(next);
66
+ };
67
+
68
+ try {
69
+ // Subscribe before fetching so no change between the two is missed.
70
+ unsubscribe = callManager.audioDevices.addChangeListener(onChange);
71
+ callManager.audioDevices
72
+ .getStatus()
73
+ .then((initial) => {
74
+ // Don't let the initial snapshot clobber newer event-driven state,
75
+ // and ignore it entirely after unmount.
76
+ if (active && !receivedUpdate) applyIfChanged(initial);
77
+ })
78
+ .catch(() => {});
79
+ } catch {
80
+ // Native/bridge error during setup — leave state undefined.
81
+ }
82
+
83
+ return () => {
84
+ active = false;
85
+ unsubscribe();
86
+ };
87
+ }, []);
88
+
89
+ return state;
90
+ };
@@ -37,7 +37,8 @@ export type ScreenShareOptions = {
37
37
  * (e.g., YouTube video audio) mixed with the user's microphone.
38
38
  *
39
39
  * - iOS in-app: Audio captured from RPScreenRecorder `.audioApp` buffers.
40
- * - iOS broadcast: Audio mixing is **not** currently supported.
40
+ * - iOS broadcast: Audio captured in the Broadcast Upload Extension and
41
+ * streamed to the app over a dedicated socket, then mixed into the audio track.
41
42
  * - Android: Audio captured via AudioPlaybackCaptureConfiguration (API 29+).
42
43
  *
43
44
  * Default: `false`.
@@ -1,6 +1,7 @@
1
1
  import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
2
2
  import type {
3
- AudioDeviceStatus,
3
+ AudioDeviceEndpointType,
4
+ AudioDevicesState,
4
5
  IOSAudioInterruptionEvent,
5
6
  StreamInCallManagerConfig,
6
7
  } from './types';
@@ -14,11 +15,24 @@ import { videoLoggerSystem } from '@stream-io/video-client';
14
15
  const NativeManager = NativeModules.StreamInCallManager;
15
16
  const CallingxModule = getCallingxLibIfAvailable();
16
17
  const AUDIO_INTERRUPTION_EVENT = 'StreamInCallManagerAudioInterruption';
18
+ const AUDIO_DEVICE_CHANGED_EVENT = 'onAudioDeviceChanged';
17
19
 
18
20
  const invariant = (condition: boolean, message: string) => {
19
21
  if (!condition) throw new Error(message);
20
22
  };
21
23
 
24
+ /**
25
+ * Runs a fire-and-forget native call, logging instead of throwing on a bridge
26
+ * error so it can't crash the caller or an event-dispatch loop.
27
+ */
28
+ const safeNativeCall = (label: string, fn: () => void): void => {
29
+ try {
30
+ fn();
31
+ } catch (error) {
32
+ videoLoggerSystem.getLogger('CallManager').warn(`${label} failed`, error);
33
+ }
34
+ };
35
+
22
36
  /**
23
37
  * On Android, whether the current call is managed by Telecom (via callingx).
24
38
  * In that mode, audio routing/mode is owned by Telecom and StreamInCallManager audio methods should not be used
@@ -41,8 +55,7 @@ const isAndroidTelecomManaged = (): boolean => {
41
55
  * the extra narrowing here is what makes that provable to the type-checker.
42
56
  */
43
57
  const getTelecomContext = ():
44
- | { cx: NonNullable<typeof CallingxModule>; callId: string }
45
- | undefined => {
58
+ { cx: NonNullable<typeof CallingxModule>; callId: string } | undefined => {
46
59
  if (!isAndroidTelecomManaged() || !CallingxModule) {
47
60
  return undefined;
48
61
  }
@@ -53,10 +66,8 @@ const getTelecomContext = ():
53
66
  return { cx: CallingxModule, callId };
54
67
  };
55
68
 
56
- /** Map a generic Telecom endpoint type to the SDK's endpoint display name. */
57
- const endpointTypeToDisplayName = (
58
- type: string,
59
- ): AudioDeviceStatus['currentEndpointType'] => {
69
+ /** Map a generic Telecom endpoint type to the SDK's endpoint type. */
70
+ const endpointTypeToDisplayName = (type: string): AudioDeviceEndpointType => {
60
71
  switch (type) {
61
72
  case 'earpiece':
62
73
  return 'Earpiece';
@@ -71,95 +82,136 @@ const endpointTypeToDisplayName = (
71
82
  }
72
83
  };
73
84
 
74
- /** Adapt a callingx endpoints snapshot to the SDK's {@link AudioDeviceStatus}. */
75
- const snapshotToStatus = (
85
+ /** Adapt a callingx endpoints snapshot to the SDK's {@link AudioDevicesState}. */
86
+ const snapshotToState = (
76
87
  snapshot: CallingxAudioSnapshot,
77
- ): AudioDeviceStatus => ({
78
- devices: snapshot.endpoints.map((e) => e.name),
88
+ ): AudioDevicesState => ({
89
+ // callingx endpoint ids are stable and unique — use them directly as the
90
+ // device id, so `select(device.id)` maps straight back to a Telecom endpoint.
91
+ devices: snapshot.endpoints.map((e) => ({
92
+ id: e.id,
93
+ name: e.name,
94
+ type: endpointTypeToDisplayName(e.type),
95
+ })),
96
+ selectedDeviceId: snapshot.currentEndpoint?.id,
79
97
  currentEndpointType: snapshot.currentEndpoint
80
98
  ? endpointTypeToDisplayName(snapshot.currentEndpoint.type)
81
99
  : 'Unknown',
82
- selectedDevice: snapshot.currentEndpoint?.name ?? '',
83
100
  });
84
101
 
85
- class AndroidCallManager {
102
+ /**
103
+ * Cross-platform audio output device picker.
104
+ */
105
+ class AudioDevicesManager {
86
106
  private eventEmitter?: NativeEventEmitter;
107
+ private interruptionReassertSetup = false;
87
108
 
88
109
  /**
89
- * Get the current audio device status.
110
+ * iOS + CallKit only: re-assert the user's output pick after an interruption ends.
111
+ *
112
+ * On interruption-end iOS clears the ephemeral route override (`setPreferredInput` /
113
+ * `overrideOutputAudioPort`) and doesn't restore it, and callingx re-applies its config
114
+ * only on engine-enable — not the engine-restart of interruption recovery.
90
115
  */
91
- getAudioDeviceStatus = async (): Promise<AudioDeviceStatus> => {
92
- invariant(Platform.OS === 'android', 'Supported only on Android');
116
+ private ensureInterruptionReassert = () => {
117
+ if (this.interruptionReassertSetup) return; // subscibed once per app lifetime
118
+ if (Platform.OS !== 'ios' || !CallingxModule) return;
119
+ this.interruptionReassertSetup = true;
120
+ CallingxModule.addEventListener('didAudioInterruption', (event) => {
121
+ if (event.phase === 'ended') {
122
+ safeNativeCall('reapplyAudioRoute', () =>
123
+ NativeManager.reapplyAudioRoute(),
124
+ );
125
+ }
126
+ });
127
+ };
128
+
129
+ /**
130
+ * Get the current audio device state (available devices + the selected one).
131
+ * Read directly from the audio session, so it works on every path.
132
+ */
133
+ getStatus = async (): Promise<AudioDevicesState> => {
134
+ // Android Telecom owns routing: read the endpoint snapshot from callingx.
93
135
  const tc = getTelecomContext();
94
136
  if (tc) {
95
137
  const snapshot = await tc.cx.getAvailableAudioEndpoints(tc.callId);
96
- return snapshotToStatus(snapshot);
138
+ return snapshotToState(snapshot);
97
139
  }
98
140
  return NativeManager.getAudioDeviceStatus();
99
141
  };
100
142
 
101
143
  /**
102
- * Switches the audio device to the specified endpoint.
144
+ * Switch the audio output to the device with the given id.
103
145
  *
104
- * @param endpointName the device name.
146
+ * @param deviceId the stable {@link AudioDevice.id} (not the display name).
105
147
  */
106
- selectAudioDevice = (endpointName: string): void => {
107
- invariant(Platform.OS === 'android', 'Supported only on Android');
148
+ select = (deviceId: string): void => {
149
+ // Android Telecom owns routing: the device id is the callingx endpoint id,
150
+ // so route by it directly (no name lookup needed).
108
151
  const tc = getTelecomContext();
109
152
  if (tc) {
110
153
  const { cx, callId } = tc;
111
- // Resolve name -> endpoint id from the current Telecom snapshot, then route via Telecom.
112
- cx.getAvailableAudioEndpoints(callId)
113
- .then((snapshot) => {
114
- const target = snapshot.endpoints.find(
115
- (e) => e.name === endpointName,
154
+ cx.requestAudioEndpointChange(callId, deviceId).catch((error) => {
155
+ videoLoggerSystem
156
+ .getLogger('CallManager')
157
+ .warn(
158
+ `select: failed to route to "${deviceId}" for call ${callId} via Telecom`,
159
+ error,
116
160
  );
117
- if (target) {
118
- return cx.requestAudioEndpointChange(callId, target.id);
119
- }
120
- return undefined;
121
- })
122
- .catch((error) => {
123
- videoLoggerSystem
124
- .getLogger('CallManager')
125
- .warn(
126
- `selectAudioDevice: failed to route to "${endpointName}" for call ${callId} via Telecom`,
127
- error,
128
- );
129
- });
161
+ });
130
162
  return;
131
163
  }
132
- NativeManager.chooseAudioDeviceEndpoint(endpointName);
164
+ this.ensureInterruptionReassert();
165
+ safeNativeCall('chooseAudioDeviceEndpoint', () =>
166
+ NativeManager.chooseAudioDeviceEndpoint(deviceId),
167
+ );
133
168
  };
134
169
 
135
170
  /**
136
- * Register a listener for audio device changes.
137
- * @param onChange callback to be called when the audio device changes.
171
+ * Register a listener for audio device changes. Returns an unsubscribe fn.
172
+ *
173
+ * @param onChange called with the latest {@link AudioDevicesState} on change.
138
174
  */
139
- addAudioDeviceChangeListener = (
140
- onChange: (audioDeviceStatus: AudioDeviceStatus) => void,
175
+ addChangeListener = (
176
+ onChange: (state: AudioDevicesState) => void,
141
177
  ): (() => void) => {
142
- invariant(Platform.OS === 'android', 'Supported only on Android');
143
- const cleanups: Array<() => void> = [];
178
+ let active = true;
179
+ const unsubscribes: Array<() => void> = [];
144
180
 
145
- // Telecom (callingx) route changes.
146
- if (CallingxModule?.isSetup && CallingxModule.isTelecomBacked) {
147
- const sub = CallingxModule.addEventListener(
148
- 'didChangeAudioEndpoints',
149
- (params) => onChange(snapshotToStatus(params)),
181
+ // callingx-owned route changes (Android Telecom + iOS CallKit). The event is
182
+ // signal-only, so re-read the current state — ignoring a resolve that lands
183
+ // after unsubscribe, and swallowing bridge rejections.
184
+ if (CallingxModule) {
185
+ const cxSub = CallingxModule.addEventListener(
186
+ 'didChangeAudioRoute',
187
+ () => {
188
+ this.getStatus()
189
+ .then((state) => {
190
+ if (active) onChange(state);
191
+ })
192
+ .catch((error) => {
193
+ videoLoggerSystem
194
+ .getLogger('CallManager')
195
+ .warn('addChangeListener: getStatus failed', error);
196
+ });
197
+ },
150
198
  );
151
- cleanups.push(() => sub.remove());
199
+ unsubscribes.push(() => cxSub.remove());
152
200
  }
153
201
 
154
- // StreamInCallManager route changes (non-Telecom calls). Native
155
- // suppresses these while in telecom-managed mode, and callingx emits no
156
- // endpoint events for non-Telecom calls, so the two sources never overlap
157
- // at runtime — safe to keep both subscribed in parallel.
158
- this.eventEmitter ??= new NativeEventEmitter(NativeManager);
159
- const s = this.eventEmitter.addListener('onAudioDeviceChanged', onChange);
160
- cleanups.push(() => s.remove());
202
+ // SDK-managed route changes (non-callingx calls).
203
+ this.eventEmitter =
204
+ this.eventEmitter ?? new NativeEventEmitter(NativeManager);
205
+ const sdkSub = this.eventEmitter.addListener(
206
+ AUDIO_DEVICE_CHANGED_EVENT,
207
+ onChange,
208
+ );
209
+ unsubscribes.push(() => sdkSub.remove());
161
210
 
162
- return () => cleanups.forEach((cleanup) => cleanup());
211
+ return () => {
212
+ active = false;
213
+ unsubscribes.forEach((unsubscribe) => unsubscribe());
214
+ };
163
215
  };
164
216
  }
165
217
 
@@ -258,7 +310,7 @@ const shouldBypassForCallKit = (): boolean => {
258
310
  };
259
311
 
260
312
  export class CallManager {
261
- android = new AndroidCallManager();
313
+ audioDevices = new AudioDevicesManager();
262
314
  ios = new IOSCallManager();
263
315
  speaker = new SpeakerManager();
264
316
 
@@ -281,11 +333,14 @@ export class CallManager {
281
333
  */
282
334
  start = (config?: StreamInCallManagerConfig): void => {
283
335
  if (shouldBypassForCallKit()) {
284
- // Forward only the passive endpoint preference; callingx reads it when
285
- // CallKit drives session activation.
286
336
  if (config?.audioRole === 'communicator' && CallingxModule) {
287
337
  const type = config.deviceEndpointType ?? 'speaker';
288
- CallingxModule.setDefaultAudioDeviceEndpointType(type);
338
+ safeNativeCall('setDefaultAudioDeviceEndpointType (callingx)', () =>
339
+ CallingxModule.setDefaultAudioDeviceEndpointType(type),
340
+ );
341
+ safeNativeCall('setDefaultAudioDeviceEndpointType', () =>
342
+ NativeManager.setDefaultAudioDeviceEndpointType(type),
343
+ );
289
344
  }
290
345
  videoLoggerSystem
291
346
  .getLogger('CallManager')
@@ -298,8 +353,10 @@ export class CallManager {
298
353
  // Telecom owns routing/focus; forward the sticky preference to callingx and run in
299
354
  // telecom-managed mode (StreamInCallManager keeps proximity/keep-screen-on only).
300
355
  if (config?.audioRole !== 'listener' && CallingxModule) {
301
- CallingxModule.setDefaultAudioDeviceEndpointType(
302
- config?.deviceEndpointType ?? 'speaker',
356
+ safeNativeCall('setDefaultAudioDeviceEndpointType (callingx)', () =>
357
+ CallingxModule.setDefaultAudioDeviceEndpointType(
358
+ config?.deviceEndpointType ?? 'speaker',
359
+ ),
303
360
  );
304
361
  }
305
362
  NativeManager.setTelecomManagedMode(true);
@@ -1,6 +1,6 @@
1
1
  import 'react-native';
2
2
  import type { NativeModule } from 'react-native';
3
- import type { AudioDeviceStatus, AudioRole, DeviceEndpointType } from './types';
3
+ import type { AudioDevicesState, AudioRole, DeviceEndpointType } from './types';
4
4
 
5
5
  export interface CallManager extends NativeModule {
6
6
  /**
@@ -33,16 +33,23 @@ export interface CallManager extends NativeModule {
33
33
  setTelecomManagedMode: (enabled: boolean) => void;
34
34
 
35
35
  /**
36
- * Choose an audio device endpoint.
37
- * @param endpointName - The name of the audio device endpoint to choose.
36
+ * Choose an audio device endpoint by its stable id.
37
+ * @param deviceId - The id of the audio device to choose (`AudioDevice.id`).
38
38
  */
39
- chooseAudioDeviceEndpoint: (endpoint: string) => void;
39
+ chooseAudioDeviceEndpoint: (deviceId: string) => void;
40
40
 
41
41
  /**
42
- * Get the current audio device status.
43
- * @returns The audio device status.
42
+ * Re-applies the current audio output pick (iOS). Used to restore a Bluetooth/wired
43
+ * route after an interruption ends, where the ephemeral route override is lost.
44
+ * No-op if no explicit pick has been made.
44
45
  */
45
- getAudioDeviceStatus: () => Promise<AudioDeviceStatus>;
46
+ reapplyAudioRoute: () => void;
47
+
48
+ /**
49
+ * Get the current audio device state (available devices + selected one).
50
+ * @returns The audio devices state.
51
+ */
52
+ getAudioDeviceStatus: () => Promise<AudioDevicesState>;
46
53
 
47
54
  /**
48
55
  * Shows the iOS audio route picker.
@@ -6,10 +6,37 @@ export type AudioDeviceEndpointType =
6
6
  | 'Unknown'
7
7
  | (string & {});
8
8
 
9
- export type AudioDeviceStatus = {
10
- devices: string[];
9
+ /**
10
+ * A selectable audio output device.
11
+ *
12
+ * Devices are identified by a stable {@link AudioDevice.id} — never by name —
13
+ * so two devices that share a display name (e.g. two "AirPods Pro") never
14
+ * collide. On iOS the id is the `AVAudioSessionPortDescription.uid`
15
+ * (or the synthetic `'speaker'` / `'earpiece'` for the built-in outputs).
16
+ * On Android it is the `AudioDeviceInfo.id` for SDK-managed routing, or the
17
+ * Telecom endpoint id (a `CallEndpointCompat` identifier) when the call is
18
+ * Telecom-managed via callingx — stable for the lifetime of that call.
19
+ */
20
+ export type AudioDevice = {
21
+ /** Stable, unique device identifier. Pass this to `callManager.audioDevices.select()`. */
22
+ id: string;
23
+ /** Human-readable label for display (e.g. "AirPods Pro", "Speaker"). Not stable — do not use as a key. */
24
+ name: string;
25
+ /** The kind of endpoint, useful for picking an icon. */
26
+ type: AudioDeviceEndpointType;
27
+ };
28
+
29
+ /**
30
+ * The current audio-output device state. Returned by
31
+ * `callManager.audioDevices.getStatus()` and the `useAudioDeviceStatus()` hook.
32
+ */
33
+ export type AudioDevicesState = {
34
+ /** All currently available output devices. */
35
+ devices: AudioDevice[];
36
+ /** The id of the active device, matching one of `devices[].id` (when known). */
37
+ selectedDeviceId?: string;
38
+ /** The endpoint type of the active device. */
11
39
  currentEndpointType: AudioDeviceEndpointType;
12
- selectedDevice: string;
13
40
  };
14
41
 
15
42
  export type AudioRole = 'communicator' | 'listener';
@@ -17,8 +17,7 @@ export function getNoiseCancellationLibThrowIfNotInstalled() {
17
17
 
18
18
  export class NoiseCancellationWrapper {
19
19
  private static noiseCancellationInstance:
20
- | InstanceType<Type['NoiseCancellation']>
21
- | undefined;
20
+ InstanceType<Type['NoiseCancellation']> | undefined;
22
21
 
23
22
  private constructor() {}
24
23
 
@@ -34,8 +34,7 @@ export const AudioInterruptionTracer = () => {
34
34
  const removeInCallManagerListener =
35
35
  callManager.ios.addAudioInterruptionListener(traceAudioInterruption);
36
36
  const callingx = getCallingxLibIfAvailable() as
37
- | CallingxAudioInterruptionEvents
38
- | undefined;
37
+ CallingxAudioInterruptionEvents | undefined;
39
38
  const callingxSubscription = callingx?.addEventListener(
40
39
  'didAudioInterruption',
41
40
  traceAudioInterruption,
@@ -17,9 +17,7 @@ export type KeepAliveAndroidNotificationTexts = {
17
17
  };
18
18
 
19
19
  export type NonRingingPushEvent =
20
- | 'call.live_started'
21
- | 'call.notification'
22
- | 'call.missed';
20
+ 'call.live_started' | 'call.notification' | 'call.missed';
23
21
 
24
22
  export type StreamVideoConfig = {
25
23
  /**
@@ -5,7 +5,6 @@
5
5
  import { NativeModules, Platform } from 'react-native';
6
6
  import type { EndCallReason } from '@stream-io/react-native-callingx';
7
7
  import { getCallingxLibIfAvailable } from '../../push/libs/callingx';
8
- import { waitForAudioSessionActivation } from './audioSessionPromise';
9
8
  import type {
10
9
  Call,
11
10
  MemberResponse,
@@ -171,9 +170,6 @@ export async function joinCallingxCall(call: Call, activeCalls: Call[]) {
171
170
  callDisplayName, // display name for display in call screen
172
171
  call.state.settings?.video?.enabled ?? false, // is video call?
173
172
  );
174
- if (Platform.OS === 'ios') {
175
- await waitForAudioSessionActivation();
176
- }
177
173
  };
178
174
 
179
175
  if (
@@ -220,10 +216,6 @@ export async function joinCallingxCall(call: Call, activeCalls: Call[]) {
220
216
  );
221
217
 
222
218
  await CallingxModule.answerIncomingCall(call.cid);
223
-
224
- if (Platform.OS === 'ios') {
225
- await waitForAudioSessionActivation();
226
- }
227
219
  } catch (error) {
228
220
  logger.error(
229
221
  `Error joining incoming call in callingx: ${call.cid}`,
@@ -8,7 +8,6 @@ import {
8
8
  clearPushWSEventSubscriptions,
9
9
  processCallFromPushInBackground,
10
10
  } from './internal/utils';
11
- import { resolvePendingAudioSession } from '../internal/callingx/audioSessionPromise';
12
11
  import {
13
12
  getCallingxLib,
14
13
  type EventData,
@@ -81,7 +80,6 @@ export function setupCallingExpEvents(pushConfig: NonNullable<PushConfig>) {
81
80
 
82
81
  const onDidActivateAudioSession = () => {
83
82
  logger.debug('callingExpDidActivateAudioSession');
84
- resolvePendingAudioSession();
85
83
  };
86
84
 
87
85
  const onDidDeactivateAudioSession = () => {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '1.41.0';
1
+ export const version = '1.43.0';