@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt +81 -0
  3. package/android/src/main/java/com/streamvideo/reactnative/recorder/AudioPipeline.kt +436 -0
  4. package/android/src/main/java/com/streamvideo/reactnative/recorder/EncoderConstants.kt +17 -0
  5. package/android/src/main/java/com/streamvideo/reactnative/recorder/PipelineHost.kt +36 -0
  6. package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderPlaybackSamplesSink.kt +60 -0
  7. package/android/src/main/java/com/streamvideo/reactnative/recorder/RecorderVideoSink.kt +31 -0
  8. package/android/src/main/java/com/streamvideo/reactnative/recorder/TracksRecorderManager.kt +329 -0
  9. package/android/src/main/java/com/streamvideo/reactnative/recorder/VideoPipeline.kt +472 -0
  10. package/dist/commonjs/hooks/index.js +11 -0
  11. package/dist/commonjs/hooks/index.js.map +1 -1
  12. package/dist/commonjs/hooks/useLoopbackRecording.js +243 -0
  13. package/dist/commonjs/hooks/useLoopbackRecording.js.map +1 -0
  14. package/dist/commonjs/utils/internal/callingx/callingx.js +18 -38
  15. package/dist/commonjs/utils/internal/callingx/callingx.js.map +1 -1
  16. package/dist/commonjs/utils/push/internal/ios.js +4 -3
  17. package/dist/commonjs/utils/push/internal/ios.js.map +1 -1
  18. package/dist/commonjs/version.js +1 -1
  19. package/dist/commonjs/version.js.map +1 -1
  20. package/dist/module/hooks/index.js +1 -0
  21. package/dist/module/hooks/index.js.map +1 -1
  22. package/dist/module/hooks/useLoopbackRecording.js +238 -0
  23. package/dist/module/hooks/useLoopbackRecording.js.map +1 -0
  24. package/dist/module/utils/internal/callingx/callingx.js +19 -39
  25. package/dist/module/utils/internal/callingx/callingx.js.map +1 -1
  26. package/dist/module/utils/push/internal/ios.js +4 -3
  27. package/dist/module/utils/push/internal/ios.js.map +1 -1
  28. package/dist/module/version.js +1 -1
  29. package/dist/module/version.js.map +1 -1
  30. package/dist/typescript/hooks/index.d.ts +1 -0
  31. package/dist/typescript/hooks/index.d.ts.map +1 -1
  32. package/dist/typescript/hooks/useLoopbackRecording.d.ts +85 -0
  33. package/dist/typescript/hooks/useLoopbackRecording.d.ts.map +1 -0
  34. package/dist/typescript/utils/internal/callingx/callingx.d.ts.map +1 -1
  35. package/dist/typescript/utils/push/internal/ios.d.ts.map +1 -1
  36. package/dist/typescript/version.d.ts +1 -1
  37. package/dist/typescript/version.d.ts.map +1 -1
  38. package/expo-config-plugin/dist/withAppDelegate.js +14 -177
  39. package/ios/RTCViewPip.swift +6 -6
  40. package/ios/RTCViewPipManager.swift +47 -10
  41. package/ios/StreamInCallManager.swift +2 -6
  42. package/ios/StreamVideoReactNative-Bridging-Header.h +2 -0
  43. package/ios/StreamVideoReactNative.h +5 -18
  44. package/ios/StreamVideoReactNative.m +83 -296
  45. package/ios/TracksRecorder/AudioPipeline.swift +270 -0
  46. package/ios/TracksRecorder/PipelineHost.swift +56 -0
  47. package/ios/TracksRecorder/RecorderAudioRenderTap.swift +154 -0
  48. package/ios/TracksRecorder/RecorderVideoSink.swift +137 -0
  49. package/ios/TracksRecorder/TracksRecorderManager.swift +327 -0
  50. package/ios/TracksRecorder/VideoPipeline.swift +297 -0
  51. package/package.json +8 -8
  52. package/src/hooks/index.ts +1 -0
  53. package/src/hooks/useLoopbackRecording.ts +438 -0
  54. package/src/utils/internal/callingx/callingx.ts +19 -44
  55. package/src/utils/push/internal/ios.ts +4 -3
  56. 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.bridge moduleForClass:[WebRTCModule class]];
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.bridge moduleForClass:[WebRTCModule class]];
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
+ }