@stream-io/video-react-native-sdk 1.42.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 (47) hide show
  1. package/CHANGELOG.md +20 -6
  2. package/dist/commonjs/components/Call/RingingCallContent/OutgoingCall.js.map +1 -1
  3. package/dist/commonjs/components/Participant/FloatingParticipantView/index.js.map +1 -1
  4. package/dist/commonjs/contexts/ThemeContext.js +10 -0
  5. package/dist/commonjs/contexts/ThemeContext.js.map +1 -1
  6. package/dist/commonjs/hooks/useScreenShareButton.js.map +1 -1
  7. package/dist/commonjs/modules/call-manager/CallManager.js.map +1 -1
  8. package/dist/commonjs/providers/NoiseCancellation/lib.js.map +1 -1
  9. package/dist/commonjs/providers/StreamCall/AudioInterruptionTracer.js.map +1 -1
  10. package/dist/commonjs/version.js +1 -1
  11. package/dist/module/components/Call/RingingCallContent/OutgoingCall.js.map +1 -1
  12. package/dist/module/components/Participant/FloatingParticipantView/index.js.map +1 -1
  13. package/dist/module/contexts/ThemeContext.js +10 -0
  14. package/dist/module/contexts/ThemeContext.js.map +1 -1
  15. package/dist/module/hooks/useScreenShareButton.js.map +1 -1
  16. package/dist/module/modules/call-manager/CallManager.js.map +1 -1
  17. package/dist/module/providers/NoiseCancellation/lib.js.map +1 -1
  18. package/dist/module/providers/StreamCall/AudioInterruptionTracer.js.map +1 -1
  19. package/dist/module/version.js +1 -1
  20. package/dist/typescript/components/Participant/FloatingParticipantView/index.d.ts.map +1 -1
  21. package/dist/typescript/contexts/ThemeContext.d.ts +10 -1
  22. package/dist/typescript/contexts/ThemeContext.d.ts.map +1 -1
  23. package/dist/typescript/hooks/useScreenShareButton.d.ts +2 -1
  24. package/dist/typescript/hooks/useScreenShareButton.d.ts.map +1 -1
  25. package/dist/typescript/modules/call-manager/CallManager.d.ts.map +1 -1
  26. package/dist/typescript/providers/NoiseCancellation/lib.d.ts.map +1 -1
  27. package/dist/typescript/providers/StreamCall/AudioInterruptionTracer.d.ts.map +1 -1
  28. package/dist/typescript/utils/StreamVideoRN/types.d.ts.map +1 -1
  29. package/dist/typescript/version.d.ts +1 -1
  30. package/expo-config-plugin/dist/withAndroidMessagingService.js +29 -3
  31. package/expo-config-plugin/dist/withIosScreenCapture/addBroadcastExtensionXcodeTarget.js +84 -2
  32. package/expo-config-plugin/dist/withIosScreenCapture/withPlistUpdates.js +3 -6
  33. package/expo-config-plugin/dist/withIosScreenCapture/withTarget.js +6 -4
  34. package/expo-config-plugin/static/AudioUploader.swift +230 -0
  35. package/expo-config-plugin/static/SampleHandler.swift +67 -14
  36. package/ios/StreamInCallManager.swift +2 -2
  37. package/ios/StreamVideoReactNative.m +15 -5
  38. package/package.json +27 -25
  39. package/src/components/Call/RingingCallContent/OutgoingCall.tsx +1 -2
  40. package/src/components/Participant/FloatingParticipantView/index.tsx +1 -4
  41. package/src/contexts/ThemeContext.tsx +10 -1
  42. package/src/hooks/useScreenShareButton.ts +2 -1
  43. package/src/modules/call-manager/CallManager.ts +1 -2
  44. package/src/providers/NoiseCancellation/lib.ts +1 -2
  45. package/src/providers/StreamCall/AudioInterruptionTracer.tsx +1 -2
  46. package/src/utils/StreamVideoRN/types.ts +1 -3
  47. package/src/version.ts +1 -1
@@ -54,6 +54,7 @@ const RN_FIREBASE_SERVICE = 'io.invertase.firebase.messaging.ReactNativeFirebase
54
54
  /** expo-notifications' FCM service — the default base auto-detected when installed. */
55
55
  const EXPO_NOTIFICATIONS_SERVICE = 'expo.modules.notifications.service.ExpoFirebaseMessagingService';
56
56
  exports.EXPO_NOTIFICATIONS_SERVICE = EXPO_NOTIFICATIONS_SERVICE;
57
+ const EXPO_NOTIFICATIONS_PACKAGE = 'expo-notifications';
57
58
  const MESSAGING_EVENT_ACTION = 'com.google.firebase.MESSAGING_EVENT';
58
59
  /**
59
60
  * The generated service lives in the `:app` module, which only has
@@ -99,13 +100,38 @@ function validateBaseClass(baseClass) {
99
100
  }
100
101
  }
101
102
  function isExpoNotificationsInstalled(projectRoot) {
103
+ return isPackageUsedByApp(EXPO_NOTIFICATIONS_PACKAGE, projectRoot);
104
+ }
105
+ /**
106
+ * Whether `packageName` belongs to this app's own dependency graph.
107
+ *
108
+ * Deliberately avoids `require.resolve`, which walks up the directory tree: in a
109
+ * workspace that also finds packages hoisted to the repo root by a *sibling*
110
+ * app. Those are not linked into this app's Android build, so extending their
111
+ * service generates Kotlin that cannot compile.
112
+ */
113
+ function isPackageUsedByApp(packageName, projectRoot) {
114
+ if (!projectRoot) {
115
+ // No project context to scope the lookup to; plain resolution is all we have.
116
+ try {
117
+ require.resolve(`${packageName}/package.json`);
118
+ return true;
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
102
124
  try {
103
- require.resolve('expo-notifications/package.json', projectRoot ? { paths: [projectRoot] } : undefined);
104
- return true;
125
+ const appPackageJson = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
126
+ // Only runtime dependencies: a devDependency is not linked into the build.
127
+ if (packageName in { ...appPackageJson?.dependencies }) {
128
+ return true;
129
+ }
105
130
  }
106
131
  catch {
107
- return false;
132
+ // Unreadable app package.json: fall through to the filesystem check.
108
133
  }
134
+ return fs.existsSync(path.join(projectRoot, 'node_modules', packageName, 'package.json'));
109
135
  }
110
136
  function resolveBaseClass(value, projectRoot) {
111
137
  if (value === null) {
@@ -33,8 +33,88 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.resolveDeploymentTarget = exports.MIN_DEPLOYMENT_TARGET = void 0;
36
37
  exports.default = addBroadcastExtensionXcodeTarget;
37
38
  const util = __importStar(require("util"));
39
+ /**
40
+ * The lowest iOS version the broadcast extension is built for.
41
+ */
42
+ exports.MIN_DEPLOYMENT_TARGET = '15.0';
43
+ /**
44
+ * A plain dotted version number, for example `15` or `16.4`. Anything else
45
+ * (`$(inherited)`, `16.4beta`) is not comparable and must not be adopted:
46
+ * `Number.parseInt` would silently accept a trailing suffix.
47
+ */
48
+ const VERSION_PATTERN = /^\d+(?:\.\d+)*$/;
49
+ /**
50
+ * Returns true when `candidate` is a higher iOS version than `current`.
51
+ * Values that are not plain version numbers are treated as not higher.
52
+ */
53
+ const isHigherVersion = (candidate, current) => {
54
+ if (!VERSION_PATTERN.test(candidate)) {
55
+ return false;
56
+ }
57
+ const parse = (value) => value.split('.').map((part) => Number.parseInt(part, 10));
58
+ const parsedCandidate = parse(candidate);
59
+ const parsedCurrent = parse(current);
60
+ const length = Math.max(parsedCandidate.length, parsedCurrent.length);
61
+ for (let index = 0; index < length; index++) {
62
+ const left = parsedCandidate[index] ?? 0;
63
+ const right = parsedCurrent[index] ?? 0;
64
+ if (left !== right) {
65
+ return left > right;
66
+ }
67
+ }
68
+ return false;
69
+ };
70
+ /**
71
+ * Deployment targets declared by the host app target and by the project itself.
72
+ *
73
+ * Scoped to those two on purpose. `pbxXCBuildConfigurationSection()` also
74
+ * returns configurations belonging to unrelated targets (widgets, share or
75
+ * notification-service extensions), and adopting the highest of those could make
76
+ * the broadcast extension require a newer iOS than the app it ships inside,
77
+ * which would silently drop screen share on devices in between.
78
+ */
79
+ const collectHostDeploymentTargets = (proj) => {
80
+ const configurations = (proj.pbxXCBuildConfigurationSection?.() ??
81
+ {});
82
+ const configurationLists = (proj.pbxXCConfigurationList?.() ?? {});
83
+ const listUuids = [
84
+ proj.getFirstTarget?.()?.firstTarget?.buildConfigurationList,
85
+ proj.getFirstProject?.()?.firstProject?.buildConfigurationList,
86
+ ].filter((uuid) => typeof uuid === 'string');
87
+ const values = [];
88
+ for (const listUuid of listUuids) {
89
+ const entries = configurationLists[listUuid]?.buildConfigurations ?? [];
90
+ for (const entry of entries) {
91
+ const value = configurations[entry?.value]?.buildSettings?.IPHONEOS_DEPLOYMENT_TARGET;
92
+ if (value === undefined || value === null) {
93
+ continue;
94
+ }
95
+ values.push(String(value).replace(/"/g, '').trim());
96
+ }
97
+ }
98
+ return values;
99
+ };
100
+ /**
101
+ * Resolves the deployment target to use for the broadcast extension.
102
+ *
103
+ * The extension mirrors the highest deployment target of its host app, so it
104
+ * never requires a newer iOS than the app itself and it satisfies the minimum
105
+ * the toolchain accepts (Xcode 27 rejects anything below 15.0). The value only
106
+ * ever moves up from {@link MIN_DEPLOYMENT_TARGET}.
107
+ */
108
+ const resolveDeploymentTarget = (proj) => {
109
+ let resolved = exports.MIN_DEPLOYMENT_TARGET;
110
+ for (const candidate of collectHostDeploymentTargets(proj)) {
111
+ if (isHigherVersion(candidate, resolved)) {
112
+ resolved = candidate;
113
+ }
114
+ }
115
+ return resolved;
116
+ };
117
+ exports.resolveDeploymentTarget = resolveDeploymentTarget;
38
118
  function addBroadcastExtensionXcodeTarget(proj, { appName, extensionName, extensionBundleIdentifier, currentProjectVersion, marketingVersion, developmentTeamId, }) {
39
119
  const targets = proj.getFirstProject().firstProject.targets ?? [];
40
120
  if (targets.length > 0 &&
@@ -50,6 +130,7 @@ function addBroadcastExtensionXcodeTarget(proj, { appName, extensionName, extens
50
130
  marketingVersion,
51
131
  extensionName,
52
132
  appName,
133
+ developmentTeamId,
53
134
  });
54
135
  const productFile = addProductFile(proj, extensionName, groupName);
55
136
  const target = addToPbxNativeTargetSection(proj, {
@@ -74,7 +155,7 @@ function addBroadcastExtensionXcodeTarget(proj, { appName, extensionName, extens
74
155
  });
75
156
  addPbxGroup(proj, productFile);
76
157
  }
77
- const addXCConfigurationList = (proj, { extensionBundleIdentifier, currentProjectVersion, marketingVersion, extensionName, }) => {
158
+ const addXCConfigurationList = (proj, { extensionBundleIdentifier, currentProjectVersion, marketingVersion, extensionName, developmentTeamId, }) => {
78
159
  const commonBuildSettings = {
79
160
  CLANG_ANALYZER_NONNULL: 'YES',
80
161
  CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION: 'YES_AGGRESSIVE',
@@ -86,12 +167,13 @@ const addXCConfigurationList = (proj, { extensionBundleIdentifier, currentProjec
86
167
  CODE_SIGN_ENTITLEMENTS: `${extensionName}/${extensionName}.entitlements`,
87
168
  CODE_SIGN_STYLE: 'Automatic',
88
169
  CURRENT_PROJECT_VERSION: currentProjectVersion,
170
+ DEVELOPMENT_TEAM: developmentTeamId,
89
171
  GCC_C_LANGUAGE_STANDARD: 'gnu11',
90
172
  GENERATE_INFOPLIST_FILE: 'YES',
91
173
  INFOPLIST_FILE: `${extensionName}/Info.plist`,
92
174
  INFOPLIST_KEY_CFBundleDisplayName: `${extensionName}`,
93
175
  INFOPLIST_KEY_NSHumanReadableCopyright: quoted(''),
94
- IPHONEOS_DEPLOYMENT_TARGET: '14.0',
176
+ IPHONEOS_DEPLOYMENT_TARGET: (0, exports.resolveDeploymentTarget)(proj),
95
177
  LD_RUNPATH_SEARCH_PATHS: quoted('$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks'),
96
178
  MARKETING_VERSION: marketingVersion,
97
179
  MTL_FAST_MATH: 'YES',
@@ -6,8 +6,7 @@ exports.default = withPlistUpdates;
6
6
  // adds the app group identifier to the app's entitlements file
7
7
  const withAppEntitlements = (configuration) => {
8
8
  return (0, config_plugins_1.withEntitlementsPlist)(configuration, (config) => {
9
- const appGroupIdentifier = `group.${config.ios
10
- .bundleIdentifier}.appgroup`;
9
+ const appGroupIdentifier = `group.${config.ios.bundleIdentifier}.appgroup`;
11
10
  config.modResults['com.apple.security.application-groups'] = [
12
11
  appGroupIdentifier,
13
12
  ];
@@ -16,10 +15,8 @@ const withAppEntitlements = (configuration) => {
16
15
  };
17
16
  const withInfoPlistRTC = (configuration) => {
18
17
  return (0, config_plugins_1.withInfoPlist)(configuration, (config) => {
19
- const appGroupIdentifier = `group.${config.ios
20
- .bundleIdentifier}.appgroup`;
21
- const extensionBundleIdentifier = `${config.ios
22
- .bundleIdentifier}.broadcast`;
18
+ const appGroupIdentifier = `group.${config.ios.bundleIdentifier}.appgroup`;
19
+ const extensionBundleIdentifier = `${config.ios.bundleIdentifier}.broadcast`;
23
20
  config.modResults.RTCAppGroupIdentifier = appGroupIdentifier;
24
21
  config.modResults.RTCScreenSharingExtension = extensionBundleIdentifier;
25
22
  if (!config.modResults.UIBackgroundModes) {
@@ -46,8 +46,7 @@ const withTarget = (configuration, props) => {
46
46
  return (0, config_plugins_1.withXcodeProject)(configuration, (config) => {
47
47
  const appName = config.modRequest.projectName;
48
48
  const extensionName = 'broadcast';
49
- const extensionBundleIdentifier = `${config.ios
50
- .bundleIdentifier}.broadcast`;
49
+ const extensionBundleIdentifier = `${config.ios.bundleIdentifier}.broadcast`;
51
50
  const currentProjectVersion = config.ios.buildNumber || '1';
52
51
  const marketingVersion = config.version;
53
52
  const proj = config.modResults;
@@ -63,8 +62,7 @@ const withTarget = (configuration, props) => {
63
62
  marketingVersion,
64
63
  developmentTeamId,
65
64
  });
66
- const appGroupIdentifier = `group.${config.ios
67
- .bundleIdentifier}.appgroup`;
65
+ const appGroupIdentifier = `group.${config.ios.bundleIdentifier}.appgroup`;
68
66
  const extensionRootPath = path.join(config.modRequest.platformProjectRoot, 'broadcast');
69
67
  addBroadcastEntitlementsFile({
70
68
  proj,
@@ -103,6 +101,7 @@ const addBroadcastSourceFiles = ({ proj, extensionRootPath, appGroupIdentifier,
103
101
  fs.copyFileSync(path.join(__dirname, '..', '..', 'static', 'Atomic.swift'), path.join(extensionRootPath, 'Atomic.swift'));
104
102
  fs.copyFileSync(path.join(__dirname, '..', '..', 'static', 'DarwinNotificationCenter.swift'), path.join(extensionRootPath, 'DarwinNotificationCenter.swift'));
105
103
  fs.copyFileSync(path.join(__dirname, '..', '..', 'static', 'SampleUploader.swift'), path.join(extensionRootPath, 'SampleUploader.swift'));
104
+ fs.copyFileSync(path.join(__dirname, '..', '..', 'static', 'AudioUploader.swift'), path.join(extensionRootPath, 'AudioUploader.swift'));
106
105
  fs.copyFileSync(path.join(__dirname, '..', '..', 'static', 'SocketConnection.swift'), path.join(extensionRootPath, 'SocketConnection.swift'));
107
106
  // Update app group bundle id in SampleHandler code
108
107
  const code = fs.readFileSync(path.join(extensionRootPath, 'SampleHandler.swift'), { encoding: 'utf-8' });
@@ -126,6 +125,9 @@ const addBroadcastSourceFiles = ({ proj, extensionRootPath, appGroupIdentifier,
126
125
  proj.addSourceFile('SampleUploader.swift', {
127
126
  target: targetUuid,
128
127
  }, groupUuid);
128
+ proj.addSourceFile('AudioUploader.swift', {
129
+ target: targetUuid,
130
+ }, groupUuid);
129
131
  proj.addSourceFile('SocketConnection.swift', {
130
132
  target: targetUuid,
131
133
  }, groupUuid);
@@ -0,0 +1,230 @@
1
+ //
2
+ // AudioUploader.swift
3
+ // Broadcast Extension
4
+ //
5
+
6
+ import AudioToolbox
7
+ import CoreMedia
8
+ import Foundation
9
+ import OSLog
10
+
11
+ private enum AudioConstants {
12
+ static let bufferMaxLength = 10240
13
+ static let magic: UInt32 = 0x5353_4155 // "SSAU"
14
+ static let headerSize = 24
15
+ }
16
+
17
+ class AudioUploader {
18
+
19
+ @Atomic private var isReady = false
20
+ private var connection: SocketConnection
21
+
22
+ private var dataToSend: Data?
23
+ private var byteIndex = 0
24
+
25
+ private let serialQueue: DispatchQueue
26
+
27
+ init(connection: SocketConnection) {
28
+ self.connection = connection
29
+ self.serialQueue = DispatchQueue(label: "org.getstream.audioUploader")
30
+
31
+ setupConnection()
32
+ }
33
+
34
+ @discardableResult func send(sample buffer: CMSampleBuffer) -> Bool {
35
+ // Drop the incoming buffer while one is still in flight. Audio buffers
36
+ // are tiny (a few KB) so this is rare; staying current beats queuing.
37
+ guard isReady else { return false }
38
+ guard let data = AudioUploader.serialize(sample: buffer) else { return false }
39
+
40
+ isReady = false
41
+ serialQueue.async { [weak self] in
42
+ self?.dataToSend = data
43
+ self?.byteIndex = 0
44
+ self?.sendDataChunk()
45
+ }
46
+
47
+ return true
48
+ }
49
+ }
50
+
51
+ private extension AudioUploader {
52
+
53
+ func setupConnection() {
54
+ connection.didOpen = { [weak self] in
55
+ self?.isReady = true
56
+ }
57
+ connection.streamHasSpaceAvailable = { [weak self] in
58
+ self?.serialQueue.async {
59
+ if let success = self?.sendDataChunk() {
60
+ self?.isReady = !success
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ @discardableResult func sendDataChunk() -> Bool {
67
+ guard let dataToSend = dataToSend else {
68
+ return false
69
+ }
70
+
71
+ var bytesLeft = dataToSend.count - byteIndex
72
+ var length = bytesLeft > AudioConstants.bufferMaxLength ? AudioConstants.bufferMaxLength : bytesLeft
73
+
74
+ length = dataToSend[byteIndex..<(byteIndex + length)].withUnsafeBytes {
75
+ guard let ptr = $0.bindMemory(to: UInt8.self).baseAddress else {
76
+ return 0
77
+ }
78
+ return connection.writeToStream(buffer: ptr, maxLength: length)
79
+ }
80
+
81
+ if length > 0 {
82
+ byteIndex += length
83
+ bytesLeft -= length
84
+
85
+ if bytesLeft == 0 {
86
+ self.dataToSend = nil
87
+ byteIndex = 0
88
+ }
89
+ } else {
90
+ os_log(.error, log: broadcastLogger, "audio writeBufferToStream failure")
91
+ }
92
+
93
+ return true
94
+ }
95
+
96
+ /// Extracts interleaved PCM + format from the sample buffer and frames it.
97
+ static func serialize(sample buffer: CMSampleBuffer) -> Data? {
98
+ guard let formatDescription = CMSampleBufferGetFormatDescription(buffer) else {
99
+ return nil
100
+ }
101
+ guard let asbdPtr = CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription) else {
102
+ return nil
103
+ }
104
+ let asbd = asbdPtr.pointee
105
+
106
+ let flags = asbd.mFormatFlags
107
+ let isFloat = (flags & kAudioFormatFlagIsFloat) != 0
108
+ let isSignedInt = (flags & kAudioFormatFlagIsSignedInteger) != 0
109
+ let isNonInterleaved = (flags & kAudioFormatFlagIsNonInterleaved) != 0
110
+ let isBigEndian = (flags & kAudioFormatFlagIsBigEndian) != 0
111
+ let bits = Int(asbd.mBitsPerChannel)
112
+ let channels = Int(asbd.mChannelsPerFrame)
113
+
114
+ guard asbd.mFormatID == kAudioFormatLinearPCM else {
115
+ return nil
116
+ }
117
+
118
+ // Only the two formats the app-side reconstruction understands.
119
+ let supported = (isFloat && bits == 32) || (isSignedInt && bits == 16)
120
+ guard supported, channels > 0 else {
121
+ return nil
122
+ }
123
+
124
+ guard var pcm = interleavedPCM(from: buffer, channels: channels, bitsPerChannel: bits, isNonInterleaved: isNonInterleaved) else {
125
+ return nil
126
+ }
127
+
128
+ if isBigEndian, bits > 8 {
129
+ pcm = byteSwapped(pcm, bytesPerSample: bits / 8)
130
+ }
131
+
132
+ // Build a length-prefixed binary frame (24-byte LE header + PCM body).
133
+ // We always emit interleaved PCM, so isInterleaved is 1.
134
+ var frame = Data(capacity: AudioConstants.headerSize + pcm.count)
135
+ appendLE(&frame, AudioConstants.magic) // 0: magic
136
+ appendLE(&frame, asbd.mSampleRate.bitPattern) // 4: sampleRate (Float64 bits)
137
+ appendLE(&frame, UInt16(channels)) // 12: channels
138
+ appendLE(&frame, UInt16(bits)) // 14: bits
139
+ frame.append(isFloat ? 1 : 0) // 16: isFloat
140
+ frame.append(1) // 17: isInterleaved
141
+ frame.append(0) // 18: reserved
142
+ frame.append(0) // 19: reserved
143
+ appendLE(&frame, UInt32(pcm.count)) // 20: payloadLength
144
+ frame.append(pcm) // 24: PCM body
145
+ return frame
146
+ }
147
+
148
+ /// Swaps byte order of each PCM sample (for 16- or 32-bit samples).
149
+ static func byteSwapped(_ data: Data, bytesPerSample: Int) -> Data {
150
+ guard bytesPerSample == 2 || bytesPerSample == 4 else { return data }
151
+ var out = data
152
+ out.withUnsafeMutableBytes { raw in
153
+ guard let base = raw.baseAddress else { return }
154
+ let count = raw.count
155
+ if bytesPerSample == 2 {
156
+ let p = base.assumingMemoryBound(to: UInt16.self)
157
+ for i in 0..<(count / 2) { p[i] = p[i].byteSwapped }
158
+ } else {
159
+ let p = base.assumingMemoryBound(to: UInt32.self)
160
+ for i in 0..<(count / 4) { p[i] = p[i].byteSwapped }
161
+ }
162
+ }
163
+ return out
164
+ }
165
+
166
+ // MARK: - Little-endian encoders
167
+
168
+ static func appendLE<T: FixedWidthInteger>(_ data: inout Data, _ value: T) {
169
+ var v = value.littleEndian
170
+ withUnsafeBytes(of: &v) { data.append(contentsOf: $0) }
171
+ }
172
+
173
+ static func interleavedPCM(from buffer: CMSampleBuffer, channels: Int, bitsPerChannel: Int, isNonInterleaved: Bool) -> Data? {
174
+ if !isNonInterleaved {
175
+ // Interleaved: the sample buffer's data buffer is already contiguous.
176
+ guard let blockBuffer = CMSampleBufferGetDataBuffer(buffer) else {
177
+ return nil
178
+ }
179
+ var length = 0
180
+ var dataPointer: UnsafeMutablePointer<Int8>?
181
+ let st = CMBlockBufferGetDataPointer(blockBuffer, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, dataPointerOut: &dataPointer)
182
+ guard st == kCMBlockBufferNoErr, let dataPointer = dataPointer, length > 0 else {
183
+ return nil
184
+ }
185
+ return Data(bytes: dataPointer, count: length)
186
+ }
187
+
188
+ // Non-interleaved (planar): use an AudioBufferList to reach each plane.
189
+ var blockBuffer: CMBlockBuffer?
190
+ let maxBuffers = max(channels, 1)
191
+ let buffers = AudioBufferList.allocate(maximumBuffers: maxBuffers)
192
+ defer { free(buffers.unsafeMutablePointer) }
193
+
194
+ let status = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(
195
+ buffer,
196
+ bufferListSizeNeededOut: nil,
197
+ bufferListOut: buffers.unsafeMutablePointer,
198
+ bufferListSize: AudioBufferList.sizeInBytes(maximumBuffers: maxBuffers),
199
+ blockBufferAllocator: nil,
200
+ blockBufferMemoryAllocator: nil,
201
+ flags: kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment,
202
+ blockBufferOut: &blockBuffer
203
+ )
204
+ guard status == noErr else {
205
+ return nil
206
+ }
207
+
208
+ let bytesPerSample = bitsPerChannel / 8
209
+ guard bytesPerSample > 0, buffers.count == channels else {
210
+ return nil
211
+ }
212
+
213
+ let minPlaneBytes = buffers.reduce(Int.max) { min($0, Int($1.mDataByteSize)) }
214
+ let frameCount = minPlaneBytes / bytesPerSample
215
+ guard frameCount > 0 else { return nil }
216
+ var output = Data(count: frameCount * channels * bytesPerSample)
217
+ output.withUnsafeMutableBytes { dstRaw in
218
+ guard let dst = dstRaw.baseAddress else { return }
219
+ for ch in 0..<channels {
220
+ guard let src = buffers[ch].mData else { continue }
221
+ for frame in 0..<frameCount {
222
+ let srcOffset = frame * bytesPerSample
223
+ let dstOffset = (frame * channels + ch) * bytesPerSample
224
+ memcpy(dst + dstOffset, src + srcOffset, bytesPerSample)
225
+ }
226
+ }
227
+ }
228
+ return output
229
+ }
230
+ }
@@ -2,6 +2,7 @@
2
2
  // SampleHandler.swift
3
3
  // Broadcast Extension
4
4
  //
5
+
5
6
  import ReplayKit
6
7
  import OSLog
7
8
 
@@ -9,26 +10,47 @@ let broadcastLogger = OSLog(subsystem: "io.getstream.reactnative", category: "Br
9
10
  private enum Constants {
10
11
  // the App Group ID value that the app and the broadcast extension targets are setup with. It differs for each app.
11
12
  static let appGroupIdentifier = "group.com.example.broadcast.appgroup"
13
+ static let videoSocketName = "rtc_SSFD"
14
+ static let audioSocketName = "rtc_SSFD_audio"
15
+ static let audioConnectMaxAttempts = 300
12
16
  }
13
17
  class SampleHandler: RPBroadcastSampleHandler {
14
18
 
15
- private var clientConnection: SocketConnection?
16
- private var uploader: SampleUploader?
19
+ private var videoClientConnection: SocketConnection?
20
+ private var videoUploader: SampleUploader?
21
+
22
+ private var audioClientConnection: SocketConnection?
23
+ private var audioUploader: AudioUploader?
17
24
 
18
25
  private var frameCount: Int = 0
19
26
 
27
+ private func socketFilePath(for name: String) -> String {
28
+ let sharedContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.appGroupIdentifier)
29
+ return sharedContainer?.appendingPathComponent(name).path ?? ""
30
+ }
31
+
20
32
  var socketFilePath: String {
21
- let sharedContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.appGroupIdentifier)
22
- return sharedContainer?.appendingPathComponent("rtc_SSFD").path ?? ""
33
+ socketFilePath(for: Constants.videoSocketName)
34
+ }
35
+
36
+ var audioSocketFilePath: String {
37
+ socketFilePath(for: Constants.audioSocketName)
23
38
  }
24
39
 
25
40
  override init() {
26
41
  super.init()
27
42
  if let connection = SocketConnection(filePath: socketFilePath) {
28
- clientConnection = connection
29
- setupConnection()
43
+ videoClientConnection = connection
44
+ setupVideoConnection()
30
45
 
31
- uploader = SampleUploader(connection: connection)
46
+ videoUploader = SampleUploader(connection: connection)
47
+ }
48
+
49
+ if let connection = SocketConnection(filePath: audioSocketFilePath) {
50
+ audioClientConnection = connection
51
+ setupAudioConnection()
52
+
53
+ audioUploader = AudioUploader(connection: connection)
32
54
  }
33
55
  os_log(.debug, log: broadcastLogger, "%{public}s", socketFilePath)
34
56
  }
@@ -38,7 +60,8 @@ class SampleHandler: RPBroadcastSampleHandler {
38
60
  frameCount = 0
39
61
 
40
62
  DarwinNotificationCenter.shared.postNotification(.broadcastStarted)
41
- openConnection()
63
+ openVideoConnection()
64
+ openAudioConnection()
42
65
  }
43
66
 
44
67
  override func broadcastPaused() {
@@ -52,14 +75,18 @@ class SampleHandler: RPBroadcastSampleHandler {
52
75
  override func broadcastFinished() {
53
76
  // User has requested to finish the broadcast.
54
77
  DarwinNotificationCenter.shared.postNotification(.broadcastStopped)
55
- clientConnection?.close()
78
+ videoClientConnection?.close()
79
+ audioClientConnection?.close()
56
80
  }
57
81
 
58
82
  override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) {
59
83
  switch sampleBufferType {
60
84
  case RPSampleBufferType.video:
61
- uploader?.send(sample: sampleBuffer)
85
+ videoUploader?.send(sample: sampleBuffer)
86
+ case RPSampleBufferType.audioApp:
87
+ audioUploader?.send(sample: sampleBuffer)
62
88
  default:
89
+ // .audioMic is handled by the app's audio device module; ignore it here.
63
90
  break
64
91
  }
65
92
  }
@@ -67,8 +94,8 @@ class SampleHandler: RPBroadcastSampleHandler {
67
94
 
68
95
  private extension SampleHandler {
69
96
 
70
- func setupConnection() {
71
- clientConnection?.didClose = { [weak self] error in
97
+ func setupVideoConnection() {
98
+ videoClientConnection?.didClose = { [weak self] error in
72
99
  os_log(.debug, log: broadcastLogger, "client connection did close \(String(describing: error))")
73
100
 
74
101
  if let error = error {
@@ -82,12 +109,19 @@ private extension SampleHandler {
82
109
  }
83
110
  }
84
111
 
85
- func openConnection() {
112
+ func setupAudioConnection() {
113
+ // closed audio socket must not tear down the whole broadcast.
114
+ audioClientConnection?.didClose = { error in
115
+ os_log(.debug, log: broadcastLogger, "audio connection did close \(String(describing: error))")
116
+ }
117
+ }
118
+
119
+ func openVideoConnection() {
86
120
  let queue = DispatchQueue(label: "broadcast.connectTimer")
87
121
  let timer = DispatchSource.makeTimerSource(queue: queue)
88
122
  timer.schedule(deadline: .now(), repeating: .milliseconds(100), leeway: .milliseconds(500))
89
123
  timer.setEventHandler { [weak self] in
90
- guard self?.clientConnection?.open() == true else {
124
+ guard self?.videoClientConnection?.open() == true else {
91
125
  return
92
126
  }
93
127
 
@@ -96,4 +130,23 @@ private extension SampleHandler {
96
130
 
97
131
  timer.resume()
98
132
  }
133
+
134
+ func openAudioConnection() {
135
+ let queue = DispatchQueue(label: "broadcast.audioConnectTimer")
136
+ let timer = DispatchSource.makeTimerSource(queue: queue)
137
+ timer.schedule(deadline: .now(), repeating: .milliseconds(100), leeway: .milliseconds(500))
138
+ var attempts = 0
139
+ timer.setEventHandler { [weak self] in
140
+ attempts += 1
141
+ if self?.audioClientConnection?.open() == true {
142
+ timer.cancel()
143
+ return
144
+ }
145
+ if attempts >= Constants.audioConnectMaxAttempts {
146
+ timer.cancel()
147
+ }
148
+ }
149
+
150
+ timer.resume()
151
+ }
99
152
  }
@@ -152,7 +152,7 @@ class StreamInCallManager: RCTEventEmitter {
152
152
  // only exists under .playAndRecord.
153
153
  category = .playback
154
154
  mode = .spokenAudio
155
- options = []
155
+ options = [.mixWithOthers]
156
156
  } else {
157
157
  // XCode 16 and older don't expose .allowBluetoothHFP
158
158
  // https://forums.swift.org/t/xcode-26-avaudiosession-categoryoptions-allowbluetooth-deprecated/80956
@@ -170,7 +170,7 @@ class StreamInCallManager: RCTEventEmitter {
170
170
  // then we always can't use defaultToSpeaker
171
171
  mustSetDefaultToSpeaker = false
172
172
  }
173
- options = mustSetDefaultToSpeaker ? [bluetoothOption, .defaultToSpeaker] : [bluetoothOption]
173
+ options = mustSetDefaultToSpeaker ? [bluetoothOption, .defaultToSpeaker, .mixWithOthers] : [bluetoothOption, .mixWithOthers]
174
174
  }
175
175
 
176
176
  let rtcConfig = RTCAudioSessionConfiguration.webRTC()