diff --git a/android/src/main/java/com/oney/WebRTCModule/GetUserMediaImpl.java b/android/src/main/java/com/oney/WebRTCModule/GetUserMediaImpl.java index b6985f7d8..e06d8c3e4 100644 --- a/android/src/main/java/com/oney/WebRTCModule/GetUserMediaImpl.java +++ b/android/src/main/java/com/oney/WebRTCModule/GetUserMediaImpl.java @@ -103,6 +103,15 @@ public void onServiceDisconnected(ComponentName name) { public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { super.onActivityResult(activity, requestCode, resultCode, data); if (requestCode == PERMISSION_REQUEST_CODE) { + // Guard against a duplicate onActivityResult dispatch. Some hosts (e.g. + // react-native-navigation) forward the activity result to every registered + // ActivityEventListener more than once, so this callback can fire twice for a + // single getDisplayMedia() request. The first pass consumes displayMediaPromise; + // a second pass would call reject()/resolve() on a null promise and crash. + if (displayMediaPromise == null) { + return; + } + if (resultCode != Activity.RESULT_OK) { displayMediaPromise.reject("DOMException", "NotAllowedError"); displayMediaPromise = null; @@ -429,12 +438,19 @@ public void run() { } private void createScreenStream() { + // A duplicate dispatch (see onActivityResult above) can schedule this more than once: a + // repeated launch() rebinds the service, so onServiceConnected can fire twice. The + // single-threaded executor runs them in order, so by the time a duplicate runs the first has + // already consumed displayMediaPromise. Bail out instead of dereferencing a null promise or + // creating a second screen stream. + if (displayMediaPromise == null) { + return; + } + final PeerConnectionFactoryProvider factoryProvider = displayMediaFactory; if (factoryProvider == null || factoryProvider.isDisposed()) { - if (displayMediaPromise != null) { - displayMediaPromise.reject("ERR_MODULE_DISPOSED", "WebRTCModule disposed during getDisplayMedia"); - displayMediaPromise = null; - } + displayMediaPromise.reject("ERR_MODULE_DISPOSED", "WebRTCModule disposed during getDisplayMedia"); + displayMediaPromise = null; displayMediaFactory = null; return; } diff --git a/ios/RCTWebRTC/ScreenCapturePickerViewManager.m b/ios/RCTWebRTC/ScreenCapturePickerViewManager.m index d5c2101fe..91e0c4c2c 100644 --- a/ios/RCTWebRTC/ScreenCapturePickerViewManager.m +++ b/ios/RCTWebRTC/ScreenCapturePickerViewManager.m @@ -1,6 +1,6 @@ #if TARGET_OS_IOS -#import +#import #import #import "ScreenCapturePickerViewManager.h" @@ -30,29 +30,28 @@ - (NSString *)preferredExtension { } RCT_EXPORT_METHOD(show : (nonnull NSNumber *)reactTag) { - [self.bridge.uiManager - addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry) { - id view = viewRegistry[reactTag]; - if (![view isKindOfClass:[RPSystemBroadcastPickerView class]]) { - RCTLogError(@"Invalid view returned from registry, expecting " - @"RPSystemBroadcastPickerView, got: %@", - view); - } else { - // Simulate a click - UIButton *btn = nil; - - for (UIView *subview in ((RPSystemBroadcastPickerView *)view).subviews) { - if ([subview isKindOfClass:[UIButton class]]) { - btn = (UIButton *)subview; - } - } - if (btn != nil) { - [btn sendActionsForControlEvents:UIControlEventTouchUpInside]; - } else { - RCTLogError(@"RPSystemBroadcastPickerView button not found"); - } + dispatch_async(dispatch_get_main_queue(), ^{ + RPSystemBroadcastPickerView *picker = self->_broadcastPickerView; + if (![picker isKindOfClass:[RPSystemBroadcastPickerView class]]) { + RCTLogError(@"Invalid broadcast picker view, expecting " + @"RPSystemBroadcastPickerView, got: %@", + picker); + return; + } + + UIButton *btn = nil; + + for (UIView *subview in picker.subviews) { + if ([subview isKindOfClass:[UIButton class]]) { + btn = (UIButton *)subview; } - }]; + } + if (btn != nil) { + [btn sendActionsForControlEvents:UIControlEventTouchUpInside]; + } else { + RCTLogError(@"RPSystemBroadcastPickerView button not found"); + } + }); } @end diff --git a/ios/RCTWebRTC/VideoCaptureController.m b/ios/RCTWebRTC/VideoCaptureController.m index 08838104f..61cc7f6e7 100644 --- a/ios/RCTWebRTC/VideoCaptureController.m +++ b/ios/RCTWebRTC/VideoCaptureController.m @@ -52,9 +52,40 @@ - (void)startCapture { return; } + // Enable Center Stage when the device supports it; cooperative with Control Center. + if (@available(iOS 16.0, *)) { + BOOL centerStageSupported = NO; + for (AVCaptureDeviceFormat *fmt in [RTCCameraVideoCapturer supportedFormatsForDevice:self.device]) { + if (fmt.isCenterStageSupported) { + centerStageSupported = YES; + break; + } + } + if (centerStageSupported) { + AVCaptureDevice.centerStageControlMode = AVCaptureCenterStageControlModeCooperative; + AVCaptureDevice.centerStageEnabled = YES; + } else if (AVCaptureDevice.isCenterStageEnabled) { + AVCaptureDevice.centerStageEnabled = NO; + } + } + AVCaptureDeviceFormat *format = [self selectFormatForDevice:self.device withTargetWidth:self.width withTargetHeight:self.height]; + + // On multi cam devices selectFormatForDevice also requires format.multiCamSupported, and that set + // can be disjoint from the Center Stage one, leaving nothing to pick. Give up Center Stage rather + // than the capture: the control mode is cooperative above, so we are allowed to turn it off, and + // doing so lets AVFoundation accept a non-Center-Stage activeFormat. + if (@available(iOS 16.0, *)) { + if (!format && AVCaptureDevice.isCenterStageEnabled) { + RCTLogWarn(@"[VideoCaptureController] No Center Stage format for device %@, disabling Center Stage", + self.device); + AVCaptureDevice.centerStageEnabled = NO; + format = [self selectFormatForDevice:self.device withTargetWidth:self.width withTargetHeight:self.height]; + } + } + if (!format) { RCTLogWarn(@"[VideoCaptureController] No valid formats for device %@", self.device); @@ -63,6 +94,17 @@ - (void)startCapture { self.selectedFormat = format; + // Clamp fps to the range Center Stage allows for this format. + int fps = self.frameRate; + if (@available(iOS 16.0, *)) { + if (AVCaptureDevice.isCenterStageEnabled) { + AVFrameRateRange *csRange = format.videoFrameRateRangeForCenterStage; + if (csRange) { + fps = MAX((int)csRange.minFrameRate, MIN(fps, (int)csRange.maxFrameRate)); + } + } + } + AVCaptureSession *session = self.capturer.captureSession; if (@available(iOS 16.0, *)) { BOOL enable = self.enableMultitaskingCameraAccess; @@ -84,7 +126,7 @@ - (void)startCapture { __weak VideoCaptureController *weakSelf = self; [self.capturer startCaptureWithDevice:self.device format:format - fps:self.frameRate + fps:fps completionHandler:^(NSError *err) { if (err) { RCTLogError(@"[VideoCaptureController] Error starting capture: %@", err); @@ -296,6 +338,11 @@ - (AVCaptureDeviceFormat *)selectFormatForDevice:(AVCaptureDevice *)device AVCaptureDeviceFormat *selectedFormat = nil; int currentDiff = INT_MAX; + BOOL centerStageEnabled = NO; + if (@available(iOS 16.0, *)) { + centerStageEnabled = AVCaptureDevice.isCenterStageEnabled; + } + for (AVCaptureDeviceFormat *format in formats) { // Only use multi cam formats when on multi cam supported devices. if (@available(iOS 13.0, macOS 14.0, tvOS 17.0, *)) { @@ -304,6 +351,13 @@ - (AVCaptureDeviceFormat *)selectFormatForDevice:(AVCaptureDevice *)device } } + // Center Stage only permits supported formats. + if (@available(iOS 16.0, *)) { + if (centerStageEnabled && !format.isCenterStageSupported) { + continue; + } + } + CMVideoDimensions dimension = CMVideoFormatDescriptionGetDimensions(format.formatDescription); FourCharCode pixelFormat = CMFormatDescriptionGetMediaSubType(format.formatDescription); int diff = abs(targetWidth - dimension.width) + abs(targetHeight - dimension.height); diff --git a/package.json b/package.json index e8ccf55aa..0521720c4 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "build": "bob build", "lint": "eslint --max-warnings 0 . && tsc --noEmit", "lintfix": "eslint --max-warnings 0 --fix . && tsc --noEmit", - "prepare": "husky install && bob build", + "prepare": "husky install && bob build && node tools/postbuild.mjs", "format": "tools/format.sh", "semantic-release": "semantic-release" }, diff --git a/src/MediaStreamTrack.ts b/src/MediaStreamTrack.ts index 9f84ddc80..ed3743b86 100644 --- a/src/MediaStreamTrack.ts +++ b/src/MediaStreamTrack.ts @@ -248,10 +248,19 @@ export default class MediaStreamTrack extends EventTarget { @@ -759,6 +752,13 @@ export default class RTCPeerConnection extends EventTarget { + const entryPath = path.join(prefix, entry.name); + + if (entry.isDirectory()) { + return findDeclarations(path.join(directory, entry.name), entryPath); + } + + return entry.name.endsWith('.d.ts') ? [ entryPath ] : []; + }); +} + +if (!fs.existsSync(output)) { + console.error(`${path.relative(root, output)} not found, run "bob build" first.`); + process.exit(1); +} + +for (const declaration of findDeclarations(source)) { + const target = path.join(declarationsOutput, declaration); + + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(path.join(source, declaration), target); + + console.info(`Copied src/${declaration} -> ${path.relative(root, target)}`); + + for (const compiledOutput of compiledOutputs) { + const compiled = path.join(compiledOutput, declaration.replace(/\.ts$/, '.js')); + + for (const artifact of [ compiled, `${compiled}.map` ]) { + if (fs.existsSync(artifact)) { + fs.rmSync(artifact); + + console.info(`Removed ${path.relative(root, artifact)}`); + } + } + } +}