@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.
- package/CHANGELOG.md +20 -6
- package/dist/commonjs/components/Call/RingingCallContent/OutgoingCall.js.map +1 -1
- package/dist/commonjs/components/Participant/FloatingParticipantView/index.js.map +1 -1
- package/dist/commonjs/contexts/ThemeContext.js +10 -0
- package/dist/commonjs/contexts/ThemeContext.js.map +1 -1
- package/dist/commonjs/hooks/useScreenShareButton.js.map +1 -1
- package/dist/commonjs/modules/call-manager/CallManager.js.map +1 -1
- package/dist/commonjs/providers/NoiseCancellation/lib.js.map +1 -1
- package/dist/commonjs/providers/StreamCall/AudioInterruptionTracer.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/module/components/Call/RingingCallContent/OutgoingCall.js.map +1 -1
- package/dist/module/components/Participant/FloatingParticipantView/index.js.map +1 -1
- package/dist/module/contexts/ThemeContext.js +10 -0
- package/dist/module/contexts/ThemeContext.js.map +1 -1
- package/dist/module/hooks/useScreenShareButton.js.map +1 -1
- package/dist/module/modules/call-manager/CallManager.js.map +1 -1
- package/dist/module/providers/NoiseCancellation/lib.js.map +1 -1
- package/dist/module/providers/StreamCall/AudioInterruptionTracer.js.map +1 -1
- package/dist/module/version.js +1 -1
- package/dist/typescript/components/Participant/FloatingParticipantView/index.d.ts.map +1 -1
- package/dist/typescript/contexts/ThemeContext.d.ts +10 -1
- package/dist/typescript/contexts/ThemeContext.d.ts.map +1 -1
- package/dist/typescript/hooks/useScreenShareButton.d.ts +2 -1
- package/dist/typescript/hooks/useScreenShareButton.d.ts.map +1 -1
- package/dist/typescript/modules/call-manager/CallManager.d.ts.map +1 -1
- package/dist/typescript/providers/NoiseCancellation/lib.d.ts.map +1 -1
- package/dist/typescript/providers/StreamCall/AudioInterruptionTracer.d.ts.map +1 -1
- package/dist/typescript/utils/StreamVideoRN/types.d.ts.map +1 -1
- package/dist/typescript/version.d.ts +1 -1
- package/expo-config-plugin/dist/withAndroidMessagingService.js +29 -3
- package/expo-config-plugin/dist/withIosScreenCapture/addBroadcastExtensionXcodeTarget.js +84 -2
- package/expo-config-plugin/dist/withIosScreenCapture/withPlistUpdates.js +3 -6
- package/expo-config-plugin/dist/withIosScreenCapture/withTarget.js +6 -4
- package/expo-config-plugin/static/AudioUploader.swift +230 -0
- package/expo-config-plugin/static/SampleHandler.swift +67 -14
- package/ios/StreamInCallManager.swift +2 -2
- package/ios/StreamVideoReactNative.m +15 -5
- package/package.json +27 -25
- package/src/components/Call/RingingCallContent/OutgoingCall.tsx +1 -2
- package/src/components/Participant/FloatingParticipantView/index.tsx +1 -4
- package/src/contexts/ThemeContext.tsx +10 -1
- package/src/hooks/useScreenShareButton.ts +2 -1
- package/src/modules/call-manager/CallManager.ts +1 -2
- package/src/providers/NoiseCancellation/lib.ts +1 -2
- package/src/providers/StreamCall/AudioInterruptionTracer.tsx +1 -2
- package/src/utils/StreamVideoRN/types.ts +1 -3
- 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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
16
|
-
private var
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
29
|
-
|
|
43
|
+
videoClientConnection = connection
|
|
44
|
+
setupVideoConnection()
|
|
30
45
|
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
71
|
-
|
|
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
|
|
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?.
|
|
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()
|