@stream-io/video-react-native-sdk 1.36.2 → 1.37.1-beta.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 +15 -0
- package/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt +81 -0
- package/android/src/main/java/com/streamvideo/reactnative/recorder/AudioPipeline.kt +436 -0
- package/android/src/main/java/com/streamvideo/reactnative/recorder/EncoderConstants.kt +17 -0
- package/android/src/main/java/com/streamvideo/reactnative/recorder/PipelineHost.kt +36 -0
- package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderPlaybackSamplesSink.kt +60 -0
- package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderVideoSink.kt +31 -0
- package/android/src/main/java/com/streamvideo/reactnative/recorder/TracksRecorderManager.kt +329 -0
- package/android/src/main/java/com/streamvideo/reactnative/recorder/VideoPipeline.kt +472 -0
- package/dist/commonjs/hooks/index.js +11 -0
- package/dist/commonjs/hooks/index.js.map +1 -1
- package/dist/commonjs/hooks/useLoopbackRecording.js +243 -0
- package/dist/commonjs/hooks/useLoopbackRecording.js.map +1 -0
- package/dist/commonjs/utils/internal/callingx/callingx.js +18 -38
- package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
- package/dist/commonjs/utils/push/internal/ios.js +4 -3
- package/dist/commonjs/utils/push/internal/ios.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/commonjs/version.js.map +1 -1
- package/dist/module/hooks/index.js +1 -0
- package/dist/module/hooks/index.js.map +1 -1
- package/dist/module/hooks/useLoopbackRecording.js +238 -0
- package/dist/module/hooks/useLoopbackRecording.js.map +1 -0
- package/dist/module/utils/internal/callingx/callingx.js +19 -39
- package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
- package/dist/module/utils/push/internal/ios.js +4 -3
- package/dist/module/utils/push/internal/ios.js.map +1 -1
- package/dist/module/version.js +1 -1
- package/dist/module/version.js.map +1 -1
- package/dist/typescript/hooks/index.d.ts +1 -0
- package/dist/typescript/hooks/index.d.ts.map +1 -1
- package/dist/typescript/hooks/useLoopbackRecording.d.ts +85 -0
- package/dist/typescript/hooks/useLoopbackRecording.d.ts.map +1 -0
- package/dist/typescript/utils/internal/callingx/callingx.d.ts.map +1 -1
- package/dist/typescript/utils/push/internal/ios.d.ts.map +1 -1
- package/dist/typescript/version.d.ts +1 -1
- package/dist/typescript/version.d.ts.map +1 -1
- package/expo-config-plugin/dist/withAppDelegate.js +14 -177
- package/ios/RTCViewPip.swift +6 -6
- package/ios/RTCViewPipManager.swift +47 -10
- package/ios/StreamInCallManager.swift +2 -6
- package/ios/StreamVideoReactNative-Bridging-Header.h +2 -0
- package/ios/StreamVideoReactNative.h +5 -18
- package/ios/StreamVideoReactNative.m +83 -296
- package/ios/TracksRecorder/AudioPipeline.swift +270 -0
- package/ios/TracksRecorder/PipelineHost.swift +56 -0
- package/ios/TracksRecorder/RecorderAudioRenderTap.swift +154 -0
- package/ios/TracksRecorder/RecorderVideoSink.swift +137 -0
- package/ios/TracksRecorder/TracksRecorderManager.swift +327 -0
- package/ios/TracksRecorder/VideoPipeline.swift +297 -0
- package/package.json +8 -8
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useLoopbackRecording.ts +438 -0
- package/src/utils/internal/callingx/callingx.ts +19 -44
- package/src/utils/push/internal/ios.ts +4 -3
- package/src/version.ts +1 -1
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
#import <React/RCTUIManagerUtils.h>
|
|
5
5
|
#import <UIKit/UIKit.h>
|
|
6
6
|
#import <CallKit/CallKit.h>
|
|
7
|
-
#import <PushKit/PushKit.h>
|
|
8
7
|
#import "StreamVideoReactNative.h"
|
|
9
8
|
#import "WebRTCModule.h"
|
|
10
9
|
#import "WebRTCModuleOptions.h"
|
|
@@ -21,12 +20,17 @@
|
|
|
21
20
|
#import <stream_react_native_webrtc/stream_react_native_webrtc-Swift.h>
|
|
22
21
|
#endif
|
|
23
22
|
|
|
23
|
+
// Import Swift-generated header for TracksRecorderManager and friends.
|
|
24
|
+
#if __has_include("stream_video_react_native-Swift.h")
|
|
25
|
+
#import "stream_video_react_native-Swift.h"
|
|
26
|
+
#elif __has_include(<stream_video_react_native/stream_video_react_native-Swift.h>)
|
|
27
|
+
#import <stream_video_react_native/stream_video_react_native-Swift.h>
|
|
28
|
+
#endif
|
|
29
|
+
|
|
24
30
|
// Do not change these consts, it is what is used react-native-webrtc
|
|
25
31
|
NSNotificationName const kBroadcastStartedNotification = @"iOS_BroadcastStarted";
|
|
26
32
|
NSNotificationName const kBroadcastStoppedNotification = @"iOS_BroadcastStopped";
|
|
27
33
|
|
|
28
|
-
static NSString *const DEFAULT_DISPLAY_NAME = @"Unknown Caller";
|
|
29
|
-
|
|
30
34
|
static dispatch_queue_t _dictionaryQueue = nil;
|
|
31
35
|
|
|
32
36
|
void broadcastNotificationCallback(CFNotificationCenterRef center,
|
|
@@ -49,9 +53,6 @@ void broadcastNotificationCallback(CFNotificationCenterRef center,
|
|
|
49
53
|
AVAudioPlayer *_busyTonePlayer; // Instance variable
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
// necessary for addUIBlock usage https://github.com/facebook/react-native/issues/50800#issuecomment-2823327307
|
|
53
|
-
@synthesize viewRegistry_DEPRECATED = _viewRegistry_DEPRECATED;
|
|
54
|
-
|
|
55
56
|
RCT_EXPORT_MODULE();
|
|
56
57
|
|
|
57
58
|
+(BOOL)requiresMainQueueSetup {
|
|
@@ -72,77 +73,6 @@ RCT_EXPORT_MODULE();
|
|
|
72
73
|
});
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
+(BOOL)canRegisterCall {
|
|
76
|
-
Class callingxClass = NSClassFromString(@"Callingx");
|
|
77
|
-
if (!callingxClass) {
|
|
78
|
-
#if DEBUG
|
|
79
|
-
NSLog(@"[StreamVideoReactNative][canRegisterCall] Callingx not available");
|
|
80
|
-
#endif
|
|
81
|
-
return YES;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
SEL selector = @selector(canRegisterCall);
|
|
85
|
-
if (![callingxClass respondsToSelector:selector]) {
|
|
86
|
-
#if DEBUG
|
|
87
|
-
NSLog(@"[StreamVideoReactNative][canRegisterCall] Callingx does not respond to canRegisterCall selector");
|
|
88
|
-
#endif
|
|
89
|
-
return YES;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
NSMethodSignature *signature = [callingxClass methodSignatureForSelector:selector];
|
|
93
|
-
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
|
|
94
|
-
[invocation setTarget:callingxClass];
|
|
95
|
-
[invocation setSelector:selector];
|
|
96
|
-
[invocation invoke];
|
|
97
|
-
|
|
98
|
-
BOOL canRegister = NO;
|
|
99
|
-
[invocation getReturnValue:&canRegister];
|
|
100
|
-
|
|
101
|
-
#if DEBUG
|
|
102
|
-
NSLog(@"[StreamVideoReactNative][canRegisterCall] canRegisterCall = %@", canRegister ? @"YES" : @"NO");
|
|
103
|
-
#endif
|
|
104
|
-
|
|
105
|
-
return canRegister;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
+(BOOL)shouldSkipIncomingPushInForeground {
|
|
109
|
-
Class callingxClass = NSClassFromString(@"Callingx");
|
|
110
|
-
if (!callingxClass) {
|
|
111
|
-
return NO;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
SEL selector = @selector(shouldSkipIncomingPushInForeground);
|
|
115
|
-
if (![callingxClass respondsToSelector:selector]) {
|
|
116
|
-
return NO;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
NSMethodSignature *signature = [callingxClass methodSignatureForSelector:selector];
|
|
120
|
-
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
|
|
121
|
-
[invocation setTarget:callingxClass];
|
|
122
|
-
[invocation setSelector:selector];
|
|
123
|
-
[invocation invoke];
|
|
124
|
-
|
|
125
|
-
BOOL shouldSkip = NO;
|
|
126
|
-
[invocation getReturnValue:&shouldSkip];
|
|
127
|
-
return shouldSkip;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
+(BOOL)isAppInForeground {
|
|
131
|
-
// applicationState must be read on the main thread (PushKit delivers on
|
|
132
|
-
// main, so the common path skips dispatch). Treat Inactive as foreground:
|
|
133
|
-
// covers brief transitions and system overlays.
|
|
134
|
-
__block UIApplicationState state = UIApplicationStateActive;
|
|
135
|
-
void (^readState)(void) = ^{
|
|
136
|
-
state = [UIApplication sharedApplication].applicationState;
|
|
137
|
-
};
|
|
138
|
-
if ([NSThread isMainThread]) {
|
|
139
|
-
readState();
|
|
140
|
-
} else {
|
|
141
|
-
dispatch_sync(dispatch_get_main_queue(), readState);
|
|
142
|
-
}
|
|
143
|
-
return state != UIApplicationStateBackground;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
76
|
+(void)voipRegistration {
|
|
147
77
|
Class voipManagerClass = NSClassFromString(@"Callingx.VoipNotificationsManager");
|
|
148
78
|
if (!voipManagerClass) {
|
|
@@ -168,223 +98,6 @@ RCT_EXPORT_MODULE();
|
|
|
168
98
|
[voipManagerClass voipRegistration];
|
|
169
99
|
}
|
|
170
100
|
|
|
171
|
-
+(void)didUpdatePushCredentials:(PKPushCredentials *)credentials forType:(NSString *)type {
|
|
172
|
-
Class voipManagerClass = NSClassFromString(@"Callingx.VoipNotificationsManager");
|
|
173
|
-
if (!voipManagerClass) {
|
|
174
|
-
// Fallback: Try the unmangled name (might work depending on Swift version)
|
|
175
|
-
voipManagerClass = NSClassFromString(@"VoipNotificationsManager");
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (!voipManagerClass) {
|
|
179
|
-
#if DEBUG
|
|
180
|
-
NSLog(@"[StreamVideoReactNative][didUpdatePushCredentials] VoipNotificationsManager not available");
|
|
181
|
-
#endif
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
SEL selector = @selector(didUpdatePushCredentials:forType:);
|
|
186
|
-
if (![voipManagerClass respondsToSelector:selector]) {
|
|
187
|
-
#if DEBUG
|
|
188
|
-
NSLog(@"[StreamVideoReactNative][didUpdatePushCredentials] VoipNotificationsManager does not respond to didUpdatePushCredentials:forType:");
|
|
189
|
-
#endif
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
[voipManagerClass didUpdatePushCredentials:credentials forType:type];
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
+(void)didReceiveIncomingPush:(PKPushPayload *)payload forType:(NSString *)type completionHandler: (void (^_Nullable)(void)) completion {
|
|
197
|
-
NSDictionary *streamPayload = payload.dictionaryPayload[@"stream"];
|
|
198
|
-
if (!streamPayload) {
|
|
199
|
-
#if DEBUG
|
|
200
|
-
NSLog(@"[StreamVideoReactNative][didReceiveIncomingPush] Stream payload not found");
|
|
201
|
-
#endif
|
|
202
|
-
if (completion) {
|
|
203
|
-
completion();
|
|
204
|
-
}
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
NSString *callCid = streamPayload[@"call_cid"];
|
|
209
|
-
if (!callCid) {
|
|
210
|
-
#if DEBUG
|
|
211
|
-
NSLog(@"[StreamVideoReactNative][didReceiveIncomingPush] Missing required field: call_cid");
|
|
212
|
-
#endif
|
|
213
|
-
if (completion) {
|
|
214
|
-
completion();
|
|
215
|
-
}
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (![StreamVideoReactNative canRegisterCall]) {
|
|
220
|
-
if (completion) {
|
|
221
|
-
completion();
|
|
222
|
-
}
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
[StreamVideoReactNative reportNewIncomingCall:streamPayload forType:type completionHandler:completion];
|
|
227
|
-
[StreamVideoReactNative didReceiveIncomingPushWithPayload:payload forType:type];
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
+(void)didReceiveIncomingVoIPPush:(PKPushPayload *)payload
|
|
231
|
-
metadata:(id _Nullable)metadata
|
|
232
|
-
completionHandler:(void (^_Nullable)(void))completion {
|
|
233
|
-
NSDictionary *streamPayload = payload.dictionaryPayload[@"stream"];
|
|
234
|
-
if (!streamPayload) {
|
|
235
|
-
#if DEBUG
|
|
236
|
-
NSLog(@"[StreamVideoReactNative][didReceiveIncomingVoIPPush] Stream payload not found");
|
|
237
|
-
#endif
|
|
238
|
-
if (completion) {
|
|
239
|
-
completion();
|
|
240
|
-
}
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
NSString *callCid = streamPayload[@"call_cid"];
|
|
245
|
-
if (!callCid) {
|
|
246
|
-
#if DEBUG
|
|
247
|
-
NSLog(@"[StreamVideoReactNative][didReceiveIncomingVoIPPush] Missing required field: call_cid");
|
|
248
|
-
#endif
|
|
249
|
-
if (completion) {
|
|
250
|
-
completion();
|
|
251
|
-
}
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
NSString *type = @"PKPushTypeVoIP";
|
|
256
|
-
BOOL mustReport = readMustReportFromMetadata(metadata);
|
|
257
|
-
|
|
258
|
-
// Both skip paths require mustReport == NO; skipping while YES risks
|
|
259
|
-
// PushKit terminating the app.
|
|
260
|
-
if (!mustReport && ![StreamVideoReactNative canRegisterCall]) {
|
|
261
|
-
// Busy reject: drop without forwarding to JS.
|
|
262
|
-
if (completion) {
|
|
263
|
-
completion();
|
|
264
|
-
}
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (!mustReport &&
|
|
269
|
-
[StreamVideoReactNative shouldSkipIncomingPushInForeground] &&
|
|
270
|
-
[StreamVideoReactNative isAppInForeground]) {
|
|
271
|
-
// Foreground skip: hide CallKit, let JS render the ringing UI.
|
|
272
|
-
[StreamVideoReactNative didReceiveIncomingPushWithPayload:payload forType:type];
|
|
273
|
-
if (completion) {
|
|
274
|
-
completion();
|
|
275
|
-
}
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
[StreamVideoReactNative reportNewIncomingCall:streamPayload forType:type completionHandler:completion];
|
|
280
|
-
[StreamVideoReactNative didReceiveIncomingPushWithPayload:payload forType:type];
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Reads `PKVoIPPushMetadata.mustReport` via runtime dispatch. Fail-safe:
|
|
284
|
-
// returns YES on any uncertainty (nil, missing property, wrong return type)
|
|
285
|
-
// so unknown metadata never causes CallKit to be skipped.
|
|
286
|
-
static BOOL readMustReportFromMetadata(id _Nullable metadata) {
|
|
287
|
-
SEL selector = @selector(mustReport);
|
|
288
|
-
if (!metadata || ![metadata respondsToSelector:selector]) {
|
|
289
|
-
return YES;
|
|
290
|
-
}
|
|
291
|
-
NSMethodSignature *signature = [metadata methodSignatureForSelector:selector];
|
|
292
|
-
if (!signature || signature.methodReturnLength != sizeof(BOOL)) {
|
|
293
|
-
return YES;
|
|
294
|
-
}
|
|
295
|
-
// BOOL encodes as "c" (legacy ABIs) or "B" (modern). Reject anything else
|
|
296
|
-
// so getReturnValue: never reads garbage from an object-returning selector.
|
|
297
|
-
const char *returnType = signature.methodReturnType;
|
|
298
|
-
if (!returnType ||
|
|
299
|
-
(strcmp(returnType, @encode(BOOL)) != 0 &&
|
|
300
|
-
strcmp(returnType, @encode(bool)) != 0)) {
|
|
301
|
-
return YES;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
|
|
305
|
-
[invocation setTarget:metadata];
|
|
306
|
-
[invocation setSelector:selector];
|
|
307
|
-
[invocation invoke];
|
|
308
|
-
|
|
309
|
-
BOOL mustReport = NO;
|
|
310
|
-
[invocation getReturnValue:&mustReport];
|
|
311
|
-
return mustReport;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
+(void)reportNewIncomingCall:(NSDictionary *)streamPayload forType:(NSString *)type completionHandler: (void (^_Nullable)(void)) completion {
|
|
315
|
-
Class callingxClass = NSClassFromString(@"Callingx");
|
|
316
|
-
if (!callingxClass) {
|
|
317
|
-
NSLog(@"[StreamVideoReactNative][didReceiveIncomingPush] Callingx not available");
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
SEL selector = @selector(reportNewIncomingCall:handle:handleType:hasVideo:localizedCallerName:supportsHolding:supportsDTMF:supportsGrouping:supportsUngrouping:payload:withCompletionHandler:);
|
|
322
|
-
if (![callingxClass respondsToSelector:selector]) {
|
|
323
|
-
#if DEBUG
|
|
324
|
-
NSLog(@"[StreamVideoReactNative][didReceiveIncomingPush] Callingx does not respond to selector");
|
|
325
|
-
#endif
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
NSString *callCid = streamPayload[@"call_cid"];
|
|
330
|
-
NSString *callDisplayName = streamPayload[@"call_display_name"];
|
|
331
|
-
NSString *createdByDisplayName = streamPayload[@"created_by_display_name"];
|
|
332
|
-
NSString *createdCallerName = callDisplayName.length > 0 ? callDisplayName : createdByDisplayName;
|
|
333
|
-
NSString *localizedCallerName = createdCallerName.length > 0 ? createdCallerName : DEFAULT_DISPLAY_NAME;
|
|
334
|
-
NSString *createdById = streamPayload[@"created_by_id"];
|
|
335
|
-
NSString *handle = createdById.length > 0 ? createdById : localizedCallerName;
|
|
336
|
-
NSString *videoIncluded = streamPayload[@"video"];
|
|
337
|
-
BOOL hasVideo = [videoIncluded isEqualToString:@"false"] ? NO : YES;
|
|
338
|
-
NSString *handleType = @"generic";
|
|
339
|
-
BOOL supportsHolding = NO;
|
|
340
|
-
BOOL supportsDTMF = NO;
|
|
341
|
-
BOOL supportsGrouping = NO;
|
|
342
|
-
BOOL supportsUngrouping = NO;
|
|
343
|
-
void (^completionHandler)(void) = completion;
|
|
344
|
-
|
|
345
|
-
NSMethodSignature *signature = [callingxClass methodSignatureForSelector:selector];
|
|
346
|
-
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
|
|
347
|
-
[invocation setTarget:callingxClass];
|
|
348
|
-
[invocation setSelector:selector];
|
|
349
|
-
[invocation setArgument:&callCid atIndex:2];
|
|
350
|
-
[invocation setArgument:&handle atIndex:3];
|
|
351
|
-
[invocation setArgument:&handleType atIndex:4];
|
|
352
|
-
[invocation setArgument:&hasVideo atIndex:5];
|
|
353
|
-
[invocation setArgument:&localizedCallerName atIndex:6];
|
|
354
|
-
[invocation setArgument:&supportsHolding atIndex:7];
|
|
355
|
-
[invocation setArgument:&supportsDTMF atIndex:8];
|
|
356
|
-
[invocation setArgument:&supportsGrouping atIndex:9];
|
|
357
|
-
[invocation setArgument:&supportsUngrouping atIndex:10];
|
|
358
|
-
[invocation setArgument:&streamPayload atIndex:11];
|
|
359
|
-
[invocation setArgument:&completionHandler atIndex:12];
|
|
360
|
-
[invocation invoke];
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
+(void)didReceiveIncomingPushWithPayload:(PKPushPayload *)payload forType:(NSString *)type {
|
|
364
|
-
Class voipManagerClass = NSClassFromString(@"Callingx.VoipNotificationsManager");
|
|
365
|
-
if (!voipManagerClass) {
|
|
366
|
-
// Fallback: Try the unmangled name (might work depending on Swift version)
|
|
367
|
-
voipManagerClass = NSClassFromString(@"VoipNotificationsManager");
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
if (!voipManagerClass) {
|
|
371
|
-
#if DEBUG
|
|
372
|
-
NSLog(@"[StreamVideoReactNative][didReceiveIncomingPushWithPayload] VoipNotificationsManager not available");
|
|
373
|
-
#endif
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
SEL selector = @selector(didReceiveIncomingPushWithPayload:forType:);
|
|
378
|
-
if (![voipManagerClass respondsToSelector:selector]) {
|
|
379
|
-
#if DEBUG
|
|
380
|
-
NSLog(@"[StreamVideoReactNative][didReceiveIncomingPushWithPayload] VoipNotificationsManager does not respond to didReceiveIncomingPushWithPayload:forType:");
|
|
381
|
-
#endif
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
[voipManagerClass didReceiveIncomingPushWithPayload:payload forType:type];
|
|
386
|
-
}
|
|
387
|
-
|
|
388
101
|
-(instancetype)init {
|
|
389
102
|
if ((self = [super init])) {
|
|
390
103
|
_notificationCenter = CFNotificationCenterGetDarwinNotifyCenter();
|
|
@@ -924,7 +637,7 @@ RCT_EXPORT_METHOD(stopInAppScreenCapture:(RCTPromiseResolveBlock)resolve
|
|
|
924
637
|
RCT_EXPORT_METHOD(startScreenShareAudioMixing:(RCTPromiseResolveBlock)resolve
|
|
925
638
|
reject:(RCTPromiseRejectBlock)reject)
|
|
926
639
|
{
|
|
927
|
-
WebRTCModule *webrtcModule = [self.
|
|
640
|
+
WebRTCModule *webrtcModule = [self.moduleRegistry moduleForName:"WebRTCModule"];
|
|
928
641
|
WebRTCModuleOptions *options = [WebRTCModuleOptions sharedInstance];
|
|
929
642
|
|
|
930
643
|
ScreenShareAudioMixer *mixer = webrtcModule.audioDeviceModule.screenShareAudioMixer;
|
|
@@ -955,7 +668,7 @@ RCT_EXPORT_METHOD(startScreenShareAudioMixing:(RCTPromiseResolveBlock)resolve
|
|
|
955
668
|
RCT_EXPORT_METHOD(stopScreenShareAudioMixing:(RCTPromiseResolveBlock)resolve
|
|
956
669
|
reject:(RCTPromiseRejectBlock)reject)
|
|
957
670
|
{
|
|
958
|
-
WebRTCModule *webrtcModule = [self.
|
|
671
|
+
WebRTCModule *webrtcModule = [self.moduleRegistry moduleForName:"WebRTCModule"];
|
|
959
672
|
WebRTCModuleOptions *options = [WebRTCModuleOptions sharedInstance];
|
|
960
673
|
|
|
961
674
|
// Stop feeding audio to the mixer
|
|
@@ -979,4 +692,78 @@ RCT_EXPORT_METHOD(stopScreenShareAudioMixing:(RCTPromiseResolveBlock)resolve
|
|
|
979
692
|
resolve(nil);
|
|
980
693
|
}
|
|
981
694
|
|
|
695
|
+
#pragma mark - Track Recording
|
|
696
|
+
|
|
697
|
+
RCT_EXPORT_METHOD(startTrackRecording:(NSDictionary *)options
|
|
698
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
699
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
700
|
+
{
|
|
701
|
+
WebRTCModule *webrtcModule = [self.bridge moduleForClass:[WebRTCModule class]];
|
|
702
|
+
if (!webrtcModule) {
|
|
703
|
+
reject(@"recording_error", @"WebRTCModule not available", nil);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
NSString *videoTrackId = options[@"videoTrackId"];
|
|
708
|
+
if (![videoTrackId isKindOfClass:[NSString class]]) videoTrackId = nil;
|
|
709
|
+
|
|
710
|
+
NSNumber *maxDuration = options[@"maxDurationMs"];
|
|
711
|
+
NSInteger maxDurationMs = ([maxDuration isKindOfClass:[NSNumber class]])
|
|
712
|
+
? [maxDuration integerValue] : 5000;
|
|
713
|
+
|
|
714
|
+
NSNumber *targetW = options[@"targetWidth"];
|
|
715
|
+
NSInteger targetWidth = ([targetW isKindOfClass:[NSNumber class]])
|
|
716
|
+
? [targetW integerValue] : 0;
|
|
717
|
+
|
|
718
|
+
NSNumber *targetH = options[@"targetHeight"];
|
|
719
|
+
NSInteger targetHeight = ([targetH isKindOfClass:[NSNumber class]])
|
|
720
|
+
? [targetH integerValue] : 0;
|
|
721
|
+
|
|
722
|
+
[[TracksRecorderManager shared]
|
|
723
|
+
startRecordingWithVideoTrackId:videoTrackId
|
|
724
|
+
maxDurationMs:maxDurationMs
|
|
725
|
+
targetWidth:targetWidth
|
|
726
|
+
targetHeight:targetHeight
|
|
727
|
+
webRTCModule:webrtcModule
|
|
728
|
+
completion:^(NSURL * _Nullable fileURL, NSError * _Nullable err) {
|
|
729
|
+
if (err) {
|
|
730
|
+
reject(@"recording_error", err.localizedDescription, err);
|
|
731
|
+
} else {
|
|
732
|
+
resolve(fileURL ? fileURL.absoluteString : [NSNull null]);
|
|
733
|
+
}
|
|
734
|
+
}];
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
RCT_EXPORT_METHOD(stopTrackRecording:(RCTPromiseResolveBlock)resolve
|
|
738
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
739
|
+
{
|
|
740
|
+
[[TracksRecorderManager shared] stopRecordingWithCompletion:^{
|
|
741
|
+
resolve(nil);
|
|
742
|
+
}];
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
RCT_EXPORT_METHOD(clearStreamRecordings:(RCTPromiseResolveBlock)resolve
|
|
746
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
747
|
+
{
|
|
748
|
+
[[TracksRecorderManager shared] clearRecordingsDirectoryWithCompletion:^(NSError * _Nullable err) {
|
|
749
|
+
if (err) {
|
|
750
|
+
reject(@"clear_error", err.localizedDescription, err);
|
|
751
|
+
} else {
|
|
752
|
+
resolve(nil);
|
|
753
|
+
}
|
|
754
|
+
}];
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
RCT_EXPORT_METHOD(getStreamRecordings:(RCTPromiseResolveBlock)resolve
|
|
758
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
759
|
+
{
|
|
760
|
+
NSArray<NSURL *> *urls = [[TracksRecorderManager shared] listRecordings];
|
|
761
|
+
NSMutableArray<NSString *> *result = [NSMutableArray arrayWithCapacity:urls.count];
|
|
762
|
+
for (NSURL *url in urls) {
|
|
763
|
+
NSString *abs = url.absoluteString;
|
|
764
|
+
if (abs) [result addObject:abs];
|
|
765
|
+
}
|
|
766
|
+
resolve(result);
|
|
767
|
+
}
|
|
768
|
+
|
|
982
769
|
@end
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright © 2026 Stream.io Inc. All rights reserved.
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import AVFoundation
|
|
6
|
+
import CoreMedia
|
|
7
|
+
import Foundation
|
|
8
|
+
import WebRTC
|
|
9
|
+
|
|
10
|
+
/// Audio pipeline owned by `TracksRecorderManager`. Encapsulates the AAC audio path:
|
|
11
|
+
/// - the `RecorderAudioRenderTap` installed on
|
|
12
|
+
/// `RTCDefaultAudioProcessingModule.renderPreProcessingDelegate`
|
|
13
|
+
/// (post-mix decoded audio, no per-track lookup required),
|
|
14
|
+
/// - the in-place speaker mute (`muteOriginal: true` on the tap; the tap
|
|
15
|
+
/// zero-fills the buffer after copying for recording),
|
|
16
|
+
/// - the AAC `AVAssetWriterInput` (writer-driven encode via
|
|
17
|
+
/// `outputSettings`),
|
|
18
|
+
/// - per-recording counters / PTS range surfaced via `logSummary` at stop.
|
|
19
|
+
///
|
|
20
|
+
/// All state mutation runs on the host's serial queue. The tap's
|
|
21
|
+
/// callback runs on a WebRTC audio thread and re-dispatches onto
|
|
22
|
+
/// `host.queue` after copying the PCM buffer.
|
|
23
|
+
internal final class AudioPipeline {
|
|
24
|
+
|
|
25
|
+
private static let aacBitRate: NSNumber = NSNumber(value: 64_000)
|
|
26
|
+
|
|
27
|
+
private weak var host: PipelineHost?
|
|
28
|
+
|
|
29
|
+
private let apm: RTCDefaultAudioProcessingModule
|
|
30
|
+
|
|
31
|
+
private var renderTap: RecorderAudioRenderTap?
|
|
32
|
+
private var audioInput: AVAssetWriterInput?
|
|
33
|
+
private var inputAdded = false
|
|
34
|
+
|
|
35
|
+
// Diagnostic counters + PTS range, surfaced via [logSummary] at stop.
|
|
36
|
+
private var buffersReceived = 0
|
|
37
|
+
private var samplesAppended = 0
|
|
38
|
+
private var buffersDropped = 0
|
|
39
|
+
private var firstSamplePtsUs: Int64 = -1
|
|
40
|
+
private var lastSamplePtsUs: Int64 = -1
|
|
41
|
+
|
|
42
|
+
// MARK: - Init
|
|
43
|
+
|
|
44
|
+
init(host: PipelineHost, apm: RTCDefaultAudioProcessingModule) {
|
|
45
|
+
self.host = host
|
|
46
|
+
self.apm = apm
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MARK: - Public API
|
|
50
|
+
|
|
51
|
+
/// Install the render-tap as the APM's `renderPreProcessingDelegate`.
|
|
52
|
+
/// The tap copies PCM into a new buffer for recording AND zero-fills the
|
|
53
|
+
/// original (post-mix decoded audio) so the speaker plays silence —
|
|
54
|
+
/// this gives "audio in the file, silence at the speaker" without
|
|
55
|
+
/// disrupting the recording. The standard `track.setVolume(0)` /
|
|
56
|
+
/// `track.isEnabled = false` mutes apply *before* this tap and would
|
|
57
|
+
/// silence the recording too.
|
|
58
|
+
func start() {
|
|
59
|
+
let tap = RecorderAudioRenderTap(muteOriginal: true) { [weak self] pcmBuffer in
|
|
60
|
+
self?.handleAudioBuffer(pcmBuffer: pcmBuffer)
|
|
61
|
+
}
|
|
62
|
+
renderTap = tap
|
|
63
|
+
apm.renderPreProcessingDelegate = tap
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// On-queue. Clear the render-tap delegate slot — only if it still
|
|
67
|
+
/// points to this pipeline's tap. If another consumer has rotated in,
|
|
68
|
+
/// leave theirs alone.
|
|
69
|
+
func detachSink() {
|
|
70
|
+
if let tap = renderTap, apm.renderPreProcessingDelegate === tap {
|
|
71
|
+
apm.renderPreProcessingDelegate = nil
|
|
72
|
+
}
|
|
73
|
+
renderTap = nil
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// On-queue. Marks the asset-writer input as finished so the writer can
|
|
77
|
+
/// finalise.
|
|
78
|
+
func markInputAsFinished() {
|
|
79
|
+
audioInput?.markAsFinished()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func logSummary() {
|
|
83
|
+
let tapCalls = renderTap?.callCount ?? -1
|
|
84
|
+
let durationMs: Int64
|
|
85
|
+
if firstSamplePtsUs >= 0 && lastSamplePtsUs >= firstSamplePtsUs {
|
|
86
|
+
durationMs = (lastSamplePtsUs - firstSamplePtsUs) / 1000
|
|
87
|
+
} else {
|
|
88
|
+
durationMs = -1
|
|
89
|
+
}
|
|
90
|
+
NSLog(
|
|
91
|
+
"[TracksRecorder.Audio] summary received=%d appended=%d dropped=%d tapCalls=%d firstPtsUs=%lld lastPtsUs=%lld durationMs=%lld",
|
|
92
|
+
buffersReceived,
|
|
93
|
+
samplesAppended,
|
|
94
|
+
buffersDropped,
|
|
95
|
+
tapCalls,
|
|
96
|
+
firstSamplePtsUs,
|
|
97
|
+
lastSamplePtsUs,
|
|
98
|
+
durationMs
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// MARK: - Tap → queue bridge
|
|
103
|
+
|
|
104
|
+
private func handleAudioBuffer(pcmBuffer: AVAudioPCMBuffer) {
|
|
105
|
+
// Unlike `VideoPipeline`'s `CVPixelBuffer` closure capture, an
|
|
106
|
+
// ARC-retained `AVAudioPCMBuffer` does *not* extend the lifetime
|
|
107
|
+
// of the underlying PCM samples — those live in WebRTC's
|
|
108
|
+
// render-buffer pool and are reused the moment this callback
|
|
109
|
+
// returns. A deep copy before the queue hop is mandatory.
|
|
110
|
+
guard let copy = AudioPipeline.deepCopyPCMBuffer(pcmBuffer) else { return }
|
|
111
|
+
guard let host = host else { return }
|
|
112
|
+
|
|
113
|
+
// `DispatchTime.now().uptimeNanoseconds` is the monotonic clock
|
|
114
|
+
// that matches `RTCVideoFrame.timeStampNs` on iOS — both reduce
|
|
115
|
+
// to `mach_absolute_time()` converted to nanoseconds, so the
|
|
116
|
+
// shared time origin works coherently across both pipelines.
|
|
117
|
+
let captureTimeNs = DispatchTime.now().uptimeNanoseconds
|
|
118
|
+
host.queue.async { [weak self] in
|
|
119
|
+
self?.handleAudioBufferOnQueue(pcmBuffer: copy, captureTimeNs: captureTimeNs)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private func handleAudioBufferOnQueue(pcmBuffer: AVAudioPCMBuffer, captureTimeNs: UInt64) {
|
|
124
|
+
guard let host = host, host.isRecording, let writer = host.assetWriter else { return }
|
|
125
|
+
|
|
126
|
+
// Lazy-create the writer's audio input on the first buffer. The
|
|
127
|
+
// input's settings depend on the runtime PCM format reported by
|
|
128
|
+
// WebRTC.
|
|
129
|
+
if audioInput == nil {
|
|
130
|
+
configureAudioInput(format: pcmBuffer.format, writer: writer)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let pts = presentationTime(host: host, timestampNs: captureTimeNs)
|
|
134
|
+
|
|
135
|
+
guard writer.status == .writing,
|
|
136
|
+
let audioInput = audioInput,
|
|
137
|
+
audioInput.isReadyForMoreMediaData else {
|
|
138
|
+
buffersDropped += 1
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
guard let sampleBuffer = AudioPipeline.makeSampleBuffer(from: pcmBuffer, pts: pts) else {
|
|
143
|
+
buffersDropped += 1
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if audioInput.append(sampleBuffer) {
|
|
148
|
+
buffersReceived += 1
|
|
149
|
+
samplesAppended += 1
|
|
150
|
+
let ptsUs = Int64(CMTimeGetSeconds(pts) * 1_000_000)
|
|
151
|
+
if firstSamplePtsUs < 0 || ptsUs < firstSamplePtsUs {
|
|
152
|
+
firstSamplePtsUs = ptsUs
|
|
153
|
+
}
|
|
154
|
+
if ptsUs > lastSamplePtsUs {
|
|
155
|
+
lastSamplePtsUs = ptsUs
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
buffersDropped += 1
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// MARK: - Asset writer input setup
|
|
163
|
+
|
|
164
|
+
private func configureAudioInput(format: AVAudioFormat, writer: AVAssetWriter) {
|
|
165
|
+
let settings: [String: Any] = [
|
|
166
|
+
AVFormatIDKey: NSNumber(value: kAudioFormatMPEG4AAC),
|
|
167
|
+
AVSampleRateKey: NSNumber(value: format.sampleRate),
|
|
168
|
+
AVNumberOfChannelsKey: NSNumber(value: format.channelCount),
|
|
169
|
+
AVEncoderBitRateKey: AudioPipeline.aacBitRate,
|
|
170
|
+
]
|
|
171
|
+
let input = AVAssetWriterInput(mediaType: .audio, outputSettings: settings)
|
|
172
|
+
input.expectsMediaDataInRealTime = true
|
|
173
|
+
|
|
174
|
+
guard writer.canAdd(input) else {
|
|
175
|
+
NSLog("[TracksRecorder.Audio] writer cannot add audio input")
|
|
176
|
+
host?.onFatalError(makeRecorderError("audio_input_add_failed", code: 4))
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
writer.add(input)
|
|
181
|
+
audioInput = input
|
|
182
|
+
inputAdded = true
|
|
183
|
+
host?.onTrackAdded()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// MARK: - PCM → CMSampleBuffer helper
|
|
187
|
+
|
|
188
|
+
/// Converts an `AVAudioPCMBuffer` into a `CMSampleBuffer` suitable for
|
|
189
|
+
/// `AVAssetWriterInput.append`. Returns `nil` if any Core Media call
|
|
190
|
+
/// fails; the caller treats that as a dropped buffer.
|
|
191
|
+
private static func makeSampleBuffer(
|
|
192
|
+
from pcmBuffer: AVAudioPCMBuffer,
|
|
193
|
+
pts: CMTime
|
|
194
|
+
) -> CMSampleBuffer? {
|
|
195
|
+
var formatDescription: CMAudioFormatDescription?
|
|
196
|
+
let createDescStatus = CMAudioFormatDescriptionCreate(
|
|
197
|
+
allocator: kCFAllocatorDefault,
|
|
198
|
+
asbd: pcmBuffer.format.streamDescription,
|
|
199
|
+
layoutSize: 0,
|
|
200
|
+
layout: nil,
|
|
201
|
+
magicCookieSize: 0,
|
|
202
|
+
magicCookie: nil,
|
|
203
|
+
extensions: nil,
|
|
204
|
+
formatDescriptionOut: &formatDescription
|
|
205
|
+
)
|
|
206
|
+
guard createDescStatus == noErr, let formatDesc = formatDescription else { return nil }
|
|
207
|
+
|
|
208
|
+
var sampleBuffer: CMSampleBuffer?
|
|
209
|
+
var timing = CMSampleTimingInfo(
|
|
210
|
+
duration: CMTime(value: 1, timescale: Int32(pcmBuffer.format.sampleRate)),
|
|
211
|
+
presentationTimeStamp: pts,
|
|
212
|
+
decodeTimeStamp: .invalid
|
|
213
|
+
)
|
|
214
|
+
let createStatus = CMSampleBufferCreate(
|
|
215
|
+
allocator: kCFAllocatorDefault,
|
|
216
|
+
dataBuffer: nil,
|
|
217
|
+
dataReady: false,
|
|
218
|
+
makeDataReadyCallback: nil,
|
|
219
|
+
refcon: nil,
|
|
220
|
+
formatDescription: formatDesc,
|
|
221
|
+
sampleCount: CMItemCount(pcmBuffer.frameLength),
|
|
222
|
+
sampleTimingEntryCount: 1,
|
|
223
|
+
sampleTimingArray: &timing,
|
|
224
|
+
sampleSizeEntryCount: 0,
|
|
225
|
+
sampleSizeArray: nil,
|
|
226
|
+
sampleBufferOut: &sampleBuffer
|
|
227
|
+
)
|
|
228
|
+
guard createStatus == noErr, let sb = sampleBuffer else { return nil }
|
|
229
|
+
|
|
230
|
+
let setStatus = CMSampleBufferSetDataBufferFromAudioBufferList(
|
|
231
|
+
sb,
|
|
232
|
+
blockBufferAllocator: kCFAllocatorDefault,
|
|
233
|
+
blockBufferMemoryAllocator: kCFAllocatorDefault,
|
|
234
|
+
flags: 0,
|
|
235
|
+
bufferList: pcmBuffer.audioBufferList
|
|
236
|
+
)
|
|
237
|
+
guard setStatus == noErr else { return nil }
|
|
238
|
+
return sb
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/// Returns a deep copy of the supplied `AVAudioPCMBuffer`. WebRTC owns
|
|
242
|
+
/// the source buffer's backing memory only for the duration of the
|
|
243
|
+
/// render-tap callback; ARC retains the wrapper across the queue hop
|
|
244
|
+
/// but not the underlying PCM samples. Copying here lets the recorder
|
|
245
|
+
/// queue read the data later without racing WebRTC's render-buffer
|
|
246
|
+
/// reuse.
|
|
247
|
+
private static func deepCopyPCMBuffer(_ source: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
|
248
|
+
guard let copy = AVAudioPCMBuffer(
|
|
249
|
+
pcmFormat: source.format,
|
|
250
|
+
frameCapacity: source.frameCapacity
|
|
251
|
+
) else { return nil }
|
|
252
|
+
copy.frameLength = source.frameLength
|
|
253
|
+
let frameLength = Int(source.frameLength)
|
|
254
|
+
let channelCount = Int(source.format.channelCount)
|
|
255
|
+
if let src = source.int16ChannelData, let dst = copy.int16ChannelData {
|
|
256
|
+
for ch in 0..<channelCount {
|
|
257
|
+
memcpy(dst[ch], src[ch], frameLength * MemoryLayout<Int16>.size)
|
|
258
|
+
}
|
|
259
|
+
} else if let src = source.floatChannelData, let dst = copy.floatChannelData {
|
|
260
|
+
for ch in 0..<channelCount {
|
|
261
|
+
memcpy(dst[ch], src[ch], frameLength * MemoryLayout<Float>.size)
|
|
262
|
+
}
|
|
263
|
+
} else if let src = source.int32ChannelData, let dst = copy.int32ChannelData {
|
|
264
|
+
for ch in 0..<channelCount {
|
|
265
|
+
memcpy(dst[ch], src[ch], frameLength * MemoryLayout<Int32>.size)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return copy
|
|
269
|
+
}
|
|
270
|
+
}
|