diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c22cd88548..0ef864dc3e 100644 --- a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/GetStream/stream-video-swift-webrtc.git", "state" : { - "revision" : "db72db9ac7e606d024ff03002c9fc1f9998dbee1", - "version" : "145.16.0" + "revision" : "61cd264753ddf22e175e2537369c3173c471c5a3", + "version" : "145.17.0" } } ], diff --git a/example/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/example/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index c22cd88548..0ef864dc3e 100644 --- a/example/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/example/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/GetStream/stream-video-swift-webrtc.git", "state" : { - "revision" : "db72db9ac7e606d024ff03002c9fc1f9998dbee1", - "version" : "145.16.0" + "revision" : "61cd264753ddf22e175e2537369c3173c471c5a3", + "version" : "145.17.0" } } ], diff --git a/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0b1fba10bd..0ef864dc3e 100644 --- a/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/GetStream/stream-video-swift-webrtc.git", "state" : { - "revision" : "f8689677681ed6b8d0f17b409cbbde67273017b2", - "version" : "145.9.0" + "revision" : "61cd264753ddf22e175e2537369c3173c471c5a3", + "version" : "145.17.0" } } ], diff --git a/example/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/example/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0b1fba10bd..0ef864dc3e 100644 --- a/example/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/example/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/GetStream/stream-video-swift-webrtc.git", "state" : { - "revision" : "f8689677681ed6b8d0f17b409cbbde67273017b2", - "version" : "145.9.0" + "revision" : "61cd264753ddf22e175e2537369c3173c471c5a3", + "version" : "145.17.0" } } ], diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCDesktopCapturer.m b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCDesktopCapturer.m index 19582a9a6f..b15f958ceb 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCDesktopCapturer.m +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCDesktopCapturer.m @@ -2,9 +2,11 @@ #import "include/stream_webrtc_flutter/FlutterRTCDesktopCapturer.h" +#if TARGET_OS_IPHONE #import #import "include/stream_webrtc_flutter/Broadcast/FlutterBroadcastScreenCapturer.h" #import "include/stream_webrtc_flutter/FlutterRPScreenRecorder.h" +#endif #import "include/stream_webrtc_flutter/LocalVideoTrack.h" #import "include/stream_webrtc_flutter/NativePeerConnectionFactory.h" #import "include/stream_webrtc_flutter/VideoProcessingAdapter.h" diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCFrameCapturer.m b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCFrameCapturer.m index 332a00cb39..cd2efbb139 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCFrameCapturer.m +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCFrameCapturer.m @@ -1,5 +1,9 @@ #import "include/stream_webrtc_flutter/FlutterRTCFrameCapturer.h" +#if TARGET_OS_IPHONE #import +#else +#import +#endif @import CoreImage; @import CoreVideo; diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCMediaStream.m b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCMediaStream.m index ee2b8ab369..3df25d9959 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCMediaStream.m +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCMediaStream.m @@ -1,7 +1,9 @@ #import "include/stream_webrtc_flutter/FlutterRTCMediaStream.h" #import #import "AVKit/AVKit.h" +#if TARGET_OS_IPHONE #import "include/stream_webrtc_flutter/AudioUtils.h" +#endif #import "include/stream_webrtc_flutter/CameraUtils.h" #import "include/stream_webrtc_flutter/FlutterRTCFrameCapturer.h" #import "include/stream_webrtc_flutter/FlutterRTCPeerConnection.h" @@ -10,9 +12,7 @@ #import "include/stream_webrtc_flutter/NativePeerConnectionFactory.h" #import "include/stream_webrtc_flutter/VideoProcessingAdapter.h" #if TARGET_OS_OSX -// Only present in the common/darwin Classes (macOS-only). The iOS tree is -// iOS-only at deploy-target level so this import is a no-op there. -#import "StreamMacAudioDevices.h" +#import "include/stream_webrtc_flutter/StreamMacAudioDevices.h" #endif @implementation RTCMediaStreamTrack (Flutter) @@ -84,7 +84,7 @@ - (RTCMediaConstraints*)defaultMediaStreamConstraints { #endif ]; -#if !defined(TARGET_OS_IPHONE) +#if TARGET_OS_OSX if (@available(macOS 13.0, *)) { deviceTypes = [deviceTypes arrayByAddingObject:AVCaptureDeviceTypeDeskViewCamera]; } @@ -157,7 +157,7 @@ - (void)getUserAudio:(NSDictionary*)constraints rtcConstraints = [self parseMediaConstraints:[self defaultAudioConstraints]]; } -#if !defined(TARGET_OS_IPHONE) +#if TARGET_OS_OSX if (audioDeviceId != nil) { [self selectAudioInput:audioDeviceId result:nil]; } diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCPeerConnection.m b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCPeerConnection.m index e22e614513..c6519d0a73 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCPeerConnection.m +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCPeerConnection.m @@ -1,15 +1,10 @@ #import "include/stream_webrtc_flutter/FlutterRTCPeerConnection.h" #import -#import "include/stream_webrtc_flutter/AudioUtils.h" +#import #import "include/stream_webrtc_flutter/FlutterRTCDataChannel.h" #import "include/stream_webrtc_flutter/FlutterWebRTCPlugin.h" #import "include/stream_webrtc_flutter/NativePeerConnectionFactory.h" -#if TARGET_OS_IPHONE -#import -#elif TARGET_OS_MAC -#import -#endif @implementation RTCPeerConnection (Flutter) @dynamic eventSink; diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m index cb54ab10c6..c58e7cfd82 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m @@ -1,5 +1,7 @@ #import "include/stream_webrtc_flutter/FlutterWebRTCPlugin.h" +#if TARGET_OS_IPHONE #import "include/stream_webrtc_flutter/AudioUtils.h" +#endif #import "include/stream_webrtc_flutter/CameraUtils.h" #import "include/stream_webrtc_flutter/AudioManager.h" @@ -7,11 +9,13 @@ #import "include/stream_webrtc_flutter/FlutterRTCDataChannel.h" #import "include/stream_webrtc_flutter/FlutterRTCDesktopCapturer.h" #import "include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h" -#import "include/stream_webrtc_flutter/FlutterRTCMediaRecorder.h" #import "include/stream_webrtc_flutter/FlutterRTCMediaStream.h" #import "include/stream_webrtc_flutter/FlutterRTCPeerConnection.h" +#if TARGET_OS_IPHONE +#import "include/stream_webrtc_flutter/FlutterRTCMediaRecorder.h" #import "include/stream_webrtc_flutter/FlutterRTCVideoPlatformViewController.h" #import "include/stream_webrtc_flutter/FlutterRTCVideoPlatformViewFactory.h" +#endif #import "include/stream_webrtc_flutter/FlutterRTCVideoRenderer.h" #import "include/stream_webrtc_flutter/ProcessorProvider.h" #import "include/stream_webrtc_flutter/VideoEffectProcessor.h" @@ -125,9 +129,9 @@ @implementation FlutterWebRTCPlugin { AVAudioSessionPort _preferredInput; #endif AudioManager* _audioManager; + BOOL _stereoPlayoutPreferred; #if TARGET_OS_IPHONE FLutterRTCVideoPlatformViewFactory* _platformViewFactory; - BOOL _stereoPlayoutPreferred; dispatch_block_t _stereoRefreshDebounceBlock; #endif @@ -213,7 +217,9 @@ - (instancetype)initWithChannel:(FlutterMethodChannel*)channel self.renders = [NSMutableDictionary new]; self.videoCapturerStopHandlers = [NSMutableDictionary new]; self.videoCaptureState = [NSMutableDictionary new]; +#if TARGET_OS_IPHONE self.recorders = [NSMutableDictionary new]; +#endif self.trackVolumeCache = [NSMutableDictionary new]; self.pausedTrackVolumes = [NSMutableDictionary new]; _factories = [NSMutableDictionary new]; @@ -2114,10 +2120,12 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } dispatch_async(nf.admQueue, ^{ if (nf.audioSuspended) { +#if TARGET_OS_IPHONE NSDictionary* audioConfigSnapshot = nf.audioConfigSnapshot; if (audioConfigSnapshot != nil) { [AudioUtils setAppleAudioConfiguration:audioConfigSnapshot]; } +#endif BOOL restorePlaying = nf.wasPlayingBeforeSuspend; BOOL restoreRecording = nf.wasRecordingBeforeSuspend; if (restorePlaying) { @@ -2417,6 +2425,10 @@ - (void)mediaStreamTrackSetVideoEffects:(nonnull NSString*)trackId } - (void)enableMultitaskingCameraAccess:(BOOL)enable result:(FlutterResult)result { +#if TARGET_OS_OSX + NSLog(@"enableMultitaskingCameraAccess: Multitasking camera access is not available on macOS."); + result(@NO); +#else @try { AVCaptureSession* session = self.videoCapturer.captureSession; if (session == nil) { @@ -2425,11 +2437,6 @@ - (void)enableMultitaskingCameraAccess:(BOOL)enable result:(FlutterResult)result return; } -#if TARGET_OS_OSX - NSLog(@"enableMultitaskingCameraAccess: Multitasking camera access is not available on macOS."); - result(@NO); - return; -#else if (@available(iOS 16.0, *)) { BOOL shouldChange = session.multitaskingCameraAccessEnabled != enable; BOOL canChange = !enable || (enable && session.isMultitaskingCameraAccessSupported); @@ -2456,12 +2463,12 @@ - (void)enableMultitaskingCameraAccess:(BOOL)enable result:(FlutterResult)result @"enableMultitaskingCameraAccess: Multitasking camera access requires iOS 16 or later."); result(@NO); } -#endif } @catch (NSException* exception) { NSLog(@"enableMultitaskingCameraAccess: Exception occurred: %@ - %@", exception.name, exception.reason); result(@NO); } +#endif } - (void)mediaStreamGetTracks:(NSString*)streamId result:(FlutterResult)result { diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m index 717514b6f5..353ab13329 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m @@ -1,5 +1,7 @@ #import "include/stream_webrtc_flutter/NativePeerConnectionFactory.h" +#if TARGET_OS_IPHONE #import "include/stream_webrtc_flutter/AudioUtils.h" +#endif #import "include/stream_webrtc_flutter/VideoFactoriesPrivate.h" #import @@ -30,9 +32,11 @@ - (instancetype)initWithFactoryId:(NSString*)factoryId dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, 0)); +#if TARGET_OS_IPHONE if (appleAudioConfiguration != nil) { [AudioUtils setAppleAudioConfiguration:appleAudioConfiguration]; } +#endif VideoDecoderFactory* decoderFactory = [[VideoDecoderFactory alloc] init]; VideoEncoderFactory* encoderFactory = [[VideoEncoderFactory alloc] init]; diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterDataPacketCryptor.h b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterDataPacketCryptor.h index 055f7c3dc6..0ef0755c3c 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterDataPacketCryptor.h +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterDataPacketCryptor.h @@ -1,4 +1,8 @@ +#if TARGET_OS_IPHONE #import +#else +#import +#endif #import #import "FlutterWebRTCPlugin.h" diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCDesktopCapturer.h b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCDesktopCapturer.h index 46099c8d1e..771dfbf06b 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCDesktopCapturer.h +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCDesktopCapturer.h @@ -1,4 +1,8 @@ +#if TARGET_OS_IPHONE #import +#else +#import +#endif #import #import diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h index 4734de9c0f..d41d929448 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h @@ -1,4 +1,8 @@ +#if TARGET_OS_IPHONE #import +#else +#import +#endif #import #import "FlutterWebRTCPlugin.h" diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCFrameCapturer.h b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCFrameCapturer.h index 8779df623c..b5c06e2a0d 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCFrameCapturer.h +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCFrameCapturer.h @@ -1,4 +1,8 @@ +#if TARGET_OS_IPHONE #import +#else +#import +#endif #import @interface FlutterRTCFrameCapturer : NSObject diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterWebRTCPlugin.h b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterWebRTCPlugin.h index 3def227d90..b10d3cf0a1 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterWebRTCPlugin.h +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterWebRTCPlugin.h @@ -1,4 +1,8 @@ +#if TARGET_OS_IPHONE #import +#else +#import +#endif #import #import #import "LocalTrack.h" @@ -6,7 +10,9 @@ @class VideoEffectProcessor; @class FlutterRTCVideoRenderer; @class FlutterRTCFrameCapturer; +#if TARGET_OS_IPHONE @class FlutterRTCMediaRecorder; +#endif @class AudioManager; @class NativePeerConnectionFactory; @@ -19,13 +25,11 @@ typedef void (^CapturerStopHandler)(CompletionHandler _Nonnull handler); @interface FlutterWebRTCPlugin : NSObject + FlutterStreamHandler> /** * Per-call factory registry, keyed by factoryId. @@ -55,8 +59,10 @@ typedef void (^CapturerStopHandler)(CompletionHandler _Nonnull handler); @property(nonatomic, strong) NSMutableDictionary>* _Nullable localTracks; @property(nonatomic, strong) NSMutableDictionary* _Nullable renders; +#if TARGET_OS_IPHONE @property(nonatomic, strong) NSMutableDictionary* _Nonnull recorders; +#endif @property(nonatomic, strong) NSMutableDictionary* _Nullable videoCapturerStopHandlers; @property(nonatomic, strong) diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/CameraUtils.m b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/CameraUtils.m index 6e5ae4efc7..fc0ce524b1 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/CameraUtils.m +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/CameraUtils.m @@ -14,8 +14,19 @@ - (AVCaptureDevice*)currentDevice { } - (void)mediaStreamTrackHasTorch:(RTCMediaStreamTrack*)track result:(FlutterResult)result { +#if TARGET_OS_IPHONE + AVCaptureDevice* device = [self currentDevice]; + + if (!device) { + NSLog(@"Video capturer is null. Can't check torch"); + result(@NO); + return; + } + result(@([device isTorchModeSupported:AVCaptureTorchModeOn])); +#else NSLog(@"Not supported on macOS. Can't check torch"); result(@NO); +#endif } - (void)mediaStreamTrackSetTorch:(RTCMediaStreamTrack*)track @@ -56,52 +67,238 @@ - (void)mediaStreamTrackSetTorch:(RTCMediaStreamTrack*)track - (void)mediaStreamTrackSetZoom:(RTCMediaStreamTrack*)track zoomLevel:(double)zoomLevel result:(FlutterResult)result { +#if TARGET_OS_IPHONE + AVCaptureDevice* device = [self currentDevice]; + if (!device) { + NSLog(@"Video capturer is null. Can't set zoom"); + result([FlutterError errorWithCode:@"mediaStreamTrackSetZoomFailed" + message:@"device is nil" + details:nil]); + return; + } + + NSError* error; + if ([device lockForConfiguration:&error] == NO) { + NSLog(@"Failed to acquire configuration lock. %@", error.localizedDescription); + result([FlutterError errorWithCode:@"mediaStreamTrackSetZoomFailed" + message:error.localizedDescription + details:nil]); + return; + } + + CGFloat desiredZoomFactor = (CGFloat)zoomLevel; + device.videoZoomFactor = MAX(1.0, MIN(desiredZoomFactor, device.activeFormat.videoMaxZoomFactor)); + [device unlockForConfiguration]; + + result(nil); +#else NSLog(@"Not supported on macOS. Can't set zoom"); result([FlutterError errorWithCode:@"mediaStreamTrackSetZoomFailed" message:@"Not supported on macOS" details:nil]); +#endif } - (void)applyFocusMode:(NSString*)focusMode onDevice:(AVCaptureDevice*)captureDevice { +#if TARGET_OS_IPHONE + [captureDevice lockForConfiguration:nil]; + if ([@"locked" isEqualToString:focusMode]) { + if ([captureDevice isFocusModeSupported:AVCaptureFocusModeAutoFocus]) { + [captureDevice setFocusMode:AVCaptureFocusModeAutoFocus]; + } + } else if ([@"auto" isEqualToString:focusMode]) { + if ([captureDevice isFocusModeSupported:AVCaptureFocusModeContinuousAutoFocus]) { + [captureDevice setFocusMode:AVCaptureFocusModeContinuousAutoFocus]; + } else if ([captureDevice isFocusModeSupported:AVCaptureFocusModeAutoFocus]) { + [captureDevice setFocusMode:AVCaptureFocusModeAutoFocus]; + } + } + [captureDevice unlockForConfiguration]; +#endif } - (void)mediaStreamTrackSetFocusMode:(nonnull RTCMediaStreamTrack*)track focusMode:(nonnull NSString*)focusMode result:(nonnull FlutterResult)result { +#if TARGET_OS_IPHONE + AVCaptureDevice* device = [self currentDevice]; + if (!device) { + NSLog(@"Video capturer is null. Can't set focusMode"); + result([FlutterError errorWithCode:@"mediaStreamTrackSetFocusModeFailed" + message:@"device is nil" + details:nil]); + return; + } + self.focusMode = focusMode; + [self applyFocusMode:focusMode onDevice:device]; + result(nil); +#else NSLog(@"Not supported on macOS. Can't focusMode"); result([FlutterError errorWithCode:@"mediaStreamTrackSetFocusModeFailed" message:@"Not supported on macOS" details:nil]); +#endif } - (void)mediaStreamTrackSetFocusPoint:(nonnull RTCMediaStreamTrack*)track focusPoint:(nonnull NSDictionary*)focusPoint result:(nonnull FlutterResult)result { +#if TARGET_OS_IPHONE + AVCaptureDevice* device = [self currentDevice]; + if (!device) { + NSLog(@"Video capturer is null. Can't set focusPoint"); + result([FlutterError errorWithCode:@"mediaStreamTrackSetFocusPointFailed" + message:@"device is nil" + details:nil]); + return; + } + BOOL reset = ((NSNumber*)focusPoint[@"reset"]).boolValue; + double x = 0.5; + double y = 0.5; + if (!reset) { + x = ((NSNumber*)focusPoint[@"x"]).doubleValue; + y = ((NSNumber*)focusPoint[@"y"]).doubleValue; + } + if (!device.isFocusPointOfInterestSupported) { + NSLog(@"Focus point of interest is not supported. Can't set focusPoint"); + result([FlutterError errorWithCode:@"mediaStreamTrackSetFocusPointFailed" + message:@"Focus point of interest is not supported" + details:nil]); + return; + } + + if (!device.isFocusPointOfInterestSupported) { + NSLog(@"Focus point of interest is not supported. Can't set focusPoint"); + result([FlutterError errorWithCode:@"mediaStreamTrackSetFocusPointFailed" + message:@"Focus point of interest is not supported" + details:nil]); + return; + } + UIDeviceOrientation orientation = [[UIDevice currentDevice] orientation]; + [device lockForConfiguration:nil]; + + [device setFocusPointOfInterest:[self getCGPointForCoordsWithOrientation:orientation x:x y:y]]; + [device unlockForConfiguration]; + + [self applyFocusMode:self.focusMode onDevice:device]; + result(nil); +#else NSLog(@"Not supported on macOS. Can't focusPoint"); result([FlutterError errorWithCode:@"mediaStreamTrackSetFocusPointFailed" message:@"Not supported on macOS" details:nil]); +#endif } - (void)applyExposureMode:(NSString*)exposureMode onDevice:(AVCaptureDevice*)captureDevice { +#if TARGET_OS_IPHONE + [captureDevice lockForConfiguration:nil]; + if ([@"locked" isEqualToString:exposureMode]) { + if ([captureDevice isExposureModeSupported:AVCaptureExposureModeAutoExpose]) { + [captureDevice setExposureMode:AVCaptureExposureModeAutoExpose]; + } + } else if ([@"auto" isEqualToString:exposureMode]) { + if ([captureDevice isExposureModeSupported:AVCaptureExposureModeContinuousAutoExposure]) { + [captureDevice setExposureMode:AVCaptureExposureModeContinuousAutoExposure]; + } else if ([captureDevice isExposureModeSupported:AVCaptureExposureModeAutoExpose]) { + [captureDevice setExposureMode:AVCaptureExposureModeAutoExpose]; + } + } + [captureDevice unlockForConfiguration]; +#endif } - (void)mediaStreamTrackSetExposureMode:(nonnull RTCMediaStreamTrack*)track exposureMode:(nonnull NSString*)exposureMode result:(nonnull FlutterResult)result { +#if TARGET_OS_IPHONE + AVCaptureDevice* device = [self currentDevice]; + if (!device) { + NSLog(@"Video capturer is null. Can't set exposureMode"); + result([FlutterError errorWithCode:@"mediaStreamTrackSetExposureModeFailed" + message:@"device is nil" + details:nil]); + return; + } + self.exposureMode = exposureMode; + [self applyExposureMode:exposureMode onDevice:device]; + result(nil); +#else NSLog(@"Not supported on macOS. Can't exposureMode"); result([FlutterError errorWithCode:@"mediaStreamTrackSetExposureModeFailed" message:@"Not supported on macOS" details:nil]); +#endif } +#if TARGET_OS_IPHONE +- (CGPoint)getCGPointForCoordsWithOrientation:(UIDeviceOrientation)orientation + x:(double)x + y:(double)y { + double oldX = x, oldY = y; + switch (orientation) { + case UIDeviceOrientationPortrait: // 90 ccw + y = 1 - oldX; + x = oldY; + break; + case UIDeviceOrientationPortraitUpsideDown: // 90 cw + x = 1 - oldY; + y = oldX; + break; + case UIDeviceOrientationLandscapeRight: // 180 + x = 1 - x; + y = 1 - y; + break; + case UIDeviceOrientationLandscapeLeft: + default: + // No rotation required + break; + } + return CGPointMake(x, y); +} +#endif + - (void)mediaStreamTrackSetExposurePoint:(nonnull RTCMediaStreamTrack*)track exposurePoint:(nonnull NSDictionary*)exposurePoint result:(nonnull FlutterResult)result { +#if TARGET_OS_IPHONE + AVCaptureDevice* device = [self currentDevice]; + + if (!device) { + NSLog(@"Video capturer is null. Can't set exposurePoint"); + result([FlutterError errorWithCode:@"mediaStreamTrackSetExposurePointFailed" + message:@"device is nil" + details:nil]); + return; + } + + BOOL reset = ((NSNumber*)exposurePoint[@"reset"]).boolValue; + double x = 0.5; + double y = 0.5; + if (!reset) { + x = ((NSNumber*)exposurePoint[@"x"]).doubleValue; + y = ((NSNumber*)exposurePoint[@"y"]).doubleValue; + } + if (!device.isExposurePointOfInterestSupported) { + NSLog(@"Exposure point of interest is not supported. Can't set exposurePoint"); + result([FlutterError errorWithCode:@"mediaStreamTrackSetExposurePointFailed" + message:@"Exposure point of interest is not supported" + details:nil]); + return; + } + UIDeviceOrientation orientation = [[UIDevice currentDevice] orientation]; + [device lockForConfiguration:nil]; + [device setExposurePointOfInterest:[self getCGPointForCoordsWithOrientation:orientation x:x y:y]]; + [device unlockForConfiguration]; + + [self applyExposureMode:self.exposureMode onDevice:device]; + result(nil); +#else NSLog(@"Not supported on macOS. Can't exposurePoint"); result([FlutterError errorWithCode:@"mediaStreamTrackSetExposurePointFailed" message:@"Not supported on macOS" details:nil]); +#endif } - (void)mediaStreamTrackSwitchCamera:(RTCMediaStreamTrack*)track result:(FlutterResult)result { @@ -116,6 +313,10 @@ - (void)mediaStreamTrackSwitchCamera:(RTCMediaStreamTrack*)track result:(Flutter return; } +#if TARGET_OS_IPHONE + [self.videoCapturer stopCapture]; +#endif + BOOL usingFrontCamera; NSInteger targetWidth; NSInteger targetHeight; diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCDesktopCapturer.m b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCDesktopCapturer.m index c0487cb63d..b15f958ceb 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCDesktopCapturer.m +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCDesktopCapturer.m @@ -2,13 +2,20 @@ #import "include/stream_webrtc_flutter/FlutterRTCDesktopCapturer.h" +#if TARGET_OS_IPHONE +#import +#import "include/stream_webrtc_flutter/Broadcast/FlutterBroadcastScreenCapturer.h" +#import "include/stream_webrtc_flutter/FlutterRPScreenRecorder.h" +#endif #import "include/stream_webrtc_flutter/LocalVideoTrack.h" #import "include/stream_webrtc_flutter/NativePeerConnectionFactory.h" #import "include/stream_webrtc_flutter/VideoProcessingAdapter.h" +#if TARGET_OS_OSX RTCDesktopMediaList* _screen = nil; RTCDesktopMediaList* _window = nil; NSArray* _captureSources; +#endif @implementation FlutterWebRTCPlugin (DesktopCapturer) @@ -30,6 +37,67 @@ - (void)getDisplayMedia:(NSDictionary*)constraints VideoProcessingAdapter* videoProcessingAdapter = [[VideoProcessingAdapter alloc] initWithRTCVideoSource:videoSource]; +#if TARGET_OS_IPHONE + BOOL useBroadcastExtension = false; + BOOL presentBroadcastPicker = false; + + id videoConstraints = constraints[@"video"]; + if ([videoConstraints isKindOfClass:[NSDictionary class]]) { + // constraints.video.deviceId + useBroadcastExtension = [((NSDictionary*)videoConstraints)[@"deviceId"] hasPrefix:@"broadcast"]; + presentBroadcastPicker = useBroadcastExtension && + ![((NSDictionary*)videoConstraints)[@"deviceId"] hasSuffix:@"-manual"]; + } + + id screenCapturer; + + if (useBroadcastExtension) { + screenCapturer = + [[FlutterBroadcastScreenCapturer alloc] initWithDelegate:videoProcessingAdapter]; + } else { + screenCapturer = + [[FlutterRPScreenRecorder alloc] initWithDelegate:[videoProcessingAdapter source]]; + } + + [screenCapturer startCapture]; + NSLog(@"start %@ capture", useBroadcastExtension ? @"broadcast" : @"replykit"); + + self.videoCapturerStopHandlers[trackUUID] = ^(CompletionHandler handler) { + NSLog(@"stop %@ capture, trackID %@", useBroadcastExtension ? @"broadcast" : @"replykit", + trackUUID); + [screenCapturer stopCaptureWithCompletionHandler:handler]; + }; + + if (presentBroadcastPicker) { + NSString* extension = + [[[NSBundle mainBundle] infoDictionary] valueForKey:kRTCScreenSharingExtension]; + + RPSystemBroadcastPickerView* picker = [[RPSystemBroadcastPickerView alloc] init]; + picker.showsMicrophoneButton = false; + if (extension) { + picker.preferredExtension = extension; + } else { + NSLog(@"Not able to find the %@ key", kRTCScreenSharingExtension); + } + // Trigger the picker's internal button via public UIControl APIs only. + // Calling the private buttonPressed: selector gets apps rejected under + // App Store Guideline 2.5.1 (non-public API). + UIButton* pickerButton = nil; + for (UIView* subview in picker.subviews) { + if ([subview isKindOfClass:[UIButton class]]) { + pickerButton = (UIButton*)subview; + break; + } + } + if (pickerButton != nil) { + [pickerButton sendActionsForControlEvents:UIControlEventTouchUpInside]; + } else { + NSLog(@"RPSystemBroadcastPickerView button not found, broadcast picker was not presented"); + } + } +#endif + +#if TARGET_OS_OSX /* example for constraints: { 'audio': false, @@ -95,16 +163,16 @@ - (void)getDisplayMedia:(NSDictionary*)constraints [desktopCapturer stopCapture]; handler(); }; +#endif - RTCVideoTrack* videoTrack = [nf.factory videoTrackWithSource:videoSource - trackId:trackUUID]; + RTCVideoTrack* videoTrack = [nf.factory videoTrackWithSource:videoSource trackId:trackUUID]; [mediaStream addVideoTrack:videoTrack]; LocalVideoTrack* localVideoTrack = [[LocalVideoTrack alloc] initWithTrack:videoTrack videoProcessing:videoProcessingAdapter]; [self.localTracks setObject:localVideoTrack forKey:trackUUID]; - + if (nf.factoryId != nil) { self.trackFactoryId[trackUUID] = nf.factoryId; } @@ -129,6 +197,7 @@ - (void)getDisplayMedia:(NSDictionary*)constraints } - (void)getDesktopSources:(NSDictionary*)argsMap result:(FlutterResult)result { +#if TARGET_OS_OSX NSLog(@"getDesktopSources"); NSArray* types = [argsMap objectForKey:@"types"]; @@ -161,9 +230,13 @@ - (void)getDesktopSources:(NSDictionary*)argsMap result:(FlutterResult)result { }]; } result(@{@"sources" : sources}); +#else + result([FlutterError errorWithCode:@"ERROR" message:@"Not supported on iOS" details:nil]); +#endif } - (void)getDesktopSourceThumbnail:(NSDictionary*)argsMap result:(FlutterResult)result { +#if TARGET_OS_OSX NSLog(@"getDesktopSourceThumbnail"); NSString* sourceId = argsMap[@"sourceId"]; RTCDesktopSource* object = [self getSourceById:sourceId]; @@ -179,9 +252,14 @@ - (void)getDesktopSourceThumbnail:(NSDictionary*)argsMap result:(FlutterResult)r } else { result(@{@"error" : @"No thumbnail found"}); } + +#else + result([FlutterError errorWithCode:@"ERROR" message:@"Not supported on iOS" details:nil]); +#endif } - (void)updateDesktopSources:(NSDictionary*)argsMap result:(FlutterResult)result { +#if TARGET_OS_OSX NSLog(@"updateDesktopSources"); NSArray* types = [argsMap objectForKey:@"types"]; if (types == nil) { @@ -193,8 +271,12 @@ - (void)updateDesktopSources:(NSDictionary*)argsMap result:(FlutterResult)result return; } result(@{@"result" : @YES}); +#else + result([FlutterError errorWithCode:@"ERROR" message:@"Not supported on iOS" details:nil]); +#endif } +#if TARGET_OS_OSX - (NSImage*)resizeImage:(NSImage*)sourceImage forSize:(CGSize)targetSize { CGSize imageSize = sourceImage.size; CGFloat width = imageSize.width; @@ -371,4 +453,6 @@ - (void)didSourceCaptureError:(RTCDesktopCapturer*)capturer { NSLog(@"didSourceCaptureError"); } +#endif + @end diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCFrameCapturer.m b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCFrameCapturer.m index e986d77df9..cd2efbb139 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCFrameCapturer.m +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCFrameCapturer.m @@ -1,5 +1,9 @@ #import "include/stream_webrtc_flutter/FlutterRTCFrameCapturer.h" +#if TARGET_OS_IPHONE +#import +#else #import +#endif @import CoreImage; @import CoreVideo; @@ -68,6 +72,14 @@ - (void)renderFrame:(nullable RTCVideoFrame*)frame { CIContext* tempContext = [CIContext contextWithOptions:nil]; CGImageRef cgImage = [tempContext createCGImage:ciImage fromRect:outputSize]; NSData* imageData; +#if TARGET_OS_IPHONE + UIImage* uiImage = [UIImage imageWithCGImage:cgImage]; + if ([[_path pathExtension] isEqualToString:@"jpg"]) { + imageData = UIImageJPEGRepresentation(uiImage, 1.0f); + } else { + imageData = UIImagePNGRepresentation(uiImage); + } +#else NSBitmapImageRep* newRep = [[NSBitmapImageRep alloc] initWithCGImage:cgImage]; [newRep setSize:NSSizeToCGSize(outputSize.size)]; NSDictionary* quality = @{NSImageCompressionFactor : @1.0f}; @@ -76,6 +88,7 @@ - (void)renderFrame:(nullable RTCVideoFrame*)frame { } else { imageData = [newRep representationUsingType:NSBitmapImageFileTypePNG properties:quality]; } +#endif CGImageRelease(cgImage); if (shouldRelease) CVPixelBufferRelease(pixelBufferRef); diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCMediaStream.m b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCMediaStream.m index a236df18c6..3df25d9959 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCMediaStream.m +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCMediaStream.m @@ -1,14 +1,19 @@ #import "include/stream_webrtc_flutter/FlutterRTCMediaStream.h" #import #import "AVKit/AVKit.h" +#if TARGET_OS_IPHONE +#import "include/stream_webrtc_flutter/AudioUtils.h" +#endif #import "include/stream_webrtc_flutter/CameraUtils.h" #import "include/stream_webrtc_flutter/FlutterRTCFrameCapturer.h" #import "include/stream_webrtc_flutter/FlutterRTCPeerConnection.h" #import "include/stream_webrtc_flutter/LocalAudioTrack.h" #import "include/stream_webrtc_flutter/LocalVideoTrack.h" #import "include/stream_webrtc_flutter/NativePeerConnectionFactory.h" -#import "include/stream_webrtc_flutter/StreamMacAudioDevices.h" #import "include/stream_webrtc_flutter/VideoProcessingAdapter.h" +#if TARGET_OS_OSX +#import "include/stream_webrtc_flutter/StreamMacAudioDevices.h" +#endif @implementation RTCMediaStreamTrack (Flutter) @@ -67,12 +72,23 @@ - (RTCMediaConstraints*)defaultMediaStreamConstraints { - (NSArray*)captureDevices { if (@available(iOS 13.0, macOS 10.15, macCatalyst 14.0, tvOS 17.0, *)) { NSArray* deviceTypes = @[ +#if TARGET_OS_IPHONE + AVCaptureDeviceTypeBuiltInTripleCamera, + AVCaptureDeviceTypeBuiltInDualCamera, + AVCaptureDeviceTypeBuiltInDualWideCamera, AVCaptureDeviceTypeBuiltInWideAngleCamera, + AVCaptureDeviceTypeBuiltInTelephotoCamera, + AVCaptureDeviceTypeBuiltInUltraWideCamera, +#else + AVCaptureDeviceTypeBuiltInWideAngleCamera, +#endif ]; +#if TARGET_OS_OSX if (@available(macOS 13.0, *)) { deviceTypes = [deviceTypes arrayByAddingObject:AVCaptureDeviceTypeDeskViewCamera]; } +#endif if (@available(iOS 17.0, macOS 14.0, tvOS 17.0, *)) { deviceTypes = [deviceTypes arrayByAddingObjectsFromArray:@[ @@ -141,9 +157,11 @@ - (void)getUserAudio:(NSDictionary*)constraints rtcConstraints = [self parseMediaConstraints:[self defaultAudioConstraints]]; } +#if TARGET_OS_OSX if (audioDeviceId != nil) { [self selectAudioInput:audioDeviceId result:nil]; } +#endif NSString* trackId = [[NSUUID UUID] UUIDString]; NativePeerConnectionFactory* nf = [self resolveFactoryForId:factoryId]; @@ -304,6 +322,7 @@ - (void)getUserMedia:(NSDictionary*)constraints BOOL requestAccessForVideo = [videoConstraints isKindOfClass:[NSNumber class]] ? [videoConstraints boolValue] : [videoConstraints isKindOfClass:[NSDictionary class]]; +#if !TARGET_IPHONE_SIMULATOR if (requestAccessForVideo) { [self requestAccessForMediaType:AVMediaTypeVideo constraints:constraints @@ -313,6 +332,7 @@ - (void)getUserMedia:(NSDictionary*)constraints factoryId:factoryId]; return; } +#endif } } @@ -575,9 +595,11 @@ - (void)getUserVideo:(NSDictionary*)constraints return; } RTCVideoSource* videoSource = [nf.factory videoSource]; +#if TARGET_OS_OSX if (self.videoCapturer) { [self.videoCapturer stopCapture]; } +#endif VideoProcessingAdapter* videoProcessingAdapter = [[VideoProcessingAdapter alloc] initWithRTCVideoSource:videoSource]; @@ -727,7 +749,9 @@ - (void)requestAccessForMediaType:(NSString*)mediaType return; } +#if TARGET_OS_OSX if (@available(macOS 10.14, *)) { +#endif [AVCaptureDevice requestAccessForMediaType:mediaType completionHandler:^(BOOL granted) { dispatch_async(dispatch_get_main_queue(), ^{ @@ -763,6 +787,7 @@ - (void)requestAccessForMediaType:(NSString*)mediaType } }); }]; +#if TARGET_OS_OSX } else { // Fallback on earlier versions NavigatorUserMediaSuccessCallback scb = ^(RTCMediaStream* mediaStream) { @@ -786,6 +811,7 @@ - (void)requestAccessForMediaType:(NSString*)mediaType factoryId:factoryId]; } } +#endif } - (void)createLocalMediaStream:(NSString*)factoryId result:(FlutterResult)result { @@ -816,6 +842,38 @@ - (void)getSources:(FlutterResult)result { @"kind" : @"videoinput", }]; } +#if TARGET_OS_IPHONE + + RTCAudioSession* session = [RTCAudioSession sharedInstance]; + for (AVAudioSessionPortDescription* port in session.session.availableInputs) { + // NSLog(@"input portName: %@, type %@", port.portName,port.portType); + [sources addObject:@{ + @"deviceId" : port.UID, + @"label" : port.portName, + @"groupId" : port.portType, + @"kind" : @"audioinput", + }]; + } + + for (AVAudioSessionPortDescription* port in session.currentRoute.outputs) { + // NSLog(@"output portName: %@, type %@", port.portName,port.portType); + if (session.currentRoute.outputs.count == 1 && ![port.UID isEqualToString:@"Speaker"]) { + [sources addObject:@{ + @"deviceId" : @"Speaker", + @"label" : @"Speaker", + @"groupId" : @"Speaker", + @"kind" : @"audiooutput", + }]; + } + [sources addObject:@{ + @"deviceId" : port.UID, + @"label" : port.portName, + @"groupId" : port.portType, + @"kind" : @"audiooutput", + }]; + } +#endif +#if TARGET_OS_OSX for (NSDictionary* d in [StreamMacAudioDevices inputDevices]) { [sources addObject:@{ @"deviceId" : d[@"deviceId"], @@ -830,10 +888,12 @@ - (void)getSources:(FlutterResult)result { @"kind" : @"audiooutput", }]; } +#endif result(@{@"sources" : sources}); } - (void)selectAudioInput:(NSString*)deviceId result:(FlutterResult)result { +#if TARGET_OS_OSX if (![StreamMacAudioDevices deviceIdExists:deviceId asInput:YES]) { if (result) result([FlutterError errorWithCode:@"selectAudioInputFailed" @@ -863,9 +923,30 @@ - (void)selectAudioInput:(NSString*)deviceId result:(FlutterResult)result { message:@"No active per-call factory; " @"join a call before selecting audio input." details:@{@"deviceId" : deviceId}]); + return; +#endif +#if TARGET_OS_IPHONE + RTCAudioSession* session = [RTCAudioSession sharedInstance]; + for (AVAudioSessionPortDescription* port in session.session.availableInputs) { + if ([port.UID isEqualToString:deviceId]) { + if (self.preferredInput != port.portType) { + self.preferredInput = port.portType; + [AudioUtils selectAudioInput:self.preferredInput]; + } + break; + } + } + if (result) + result(nil); +#endif + if (result) + result([FlutterError errorWithCode:@"selectAudioInputFailed" + message:[NSString stringWithFormat:@"Error: deviceId not found!"] + details:nil]); } - (void)selectAudioOutput:(NSString*)deviceId result:(FlutterResult)result { +#if TARGET_OS_OSX if (![StreamMacAudioDevices deviceIdExists:deviceId asInput:NO]) { result([FlutterError errorWithCode:@"selectAudioOutputFailed" message:@"Error: deviceId not found!" @@ -893,13 +974,90 @@ - (void)selectAudioOutput:(NSString*)deviceId result:(FlutterResult)result { message:@"No active per-call factory; " @"join a call before selecting audio output." details:@{@"deviceId" : deviceId}]); + return; +#endif +#if TARGET_OS_IPHONE + RTCAudioSession* session = [RTCAudioSession sharedInstance]; + NSError* setCategoryError = nil; + + if ([deviceId isEqualToString:@"Speaker"]) { + [session.session overrideOutputAudioPort:kAudioSessionOverrideAudioRoute_Speaker + error:&setCategoryError]; + } else { + [session.session overrideOutputAudioPort:kAudioSessionOverrideAudioRoute_None + error:&setCategoryError]; + } + + if (setCategoryError == nil) { + result(nil); + return; + } + + result([FlutterError + errorWithCode:@"selectAudioOutputFailed" + message:[NSString + stringWithFormat:@"Error: %@", [setCategoryError localizedFailureReason]] + details:nil]); + +#endif + result([FlutterError errorWithCode:@"selectAudioOutputFailed" + message:[NSString stringWithFormat:@"Error: deviceId not found!"] + details:nil]); } - (void)triggeriOSAudioRouteSelectionUI:(FlutterResult)result { +#if TARGET_OS_IPHONE + if (@available(iOS 11.0, *)) { + AVRoutePickerView* routePicker = [[AVRoutePickerView alloc] init]; + routePicker.frame = CGRectMake(0, 0, 44, 44); + + // Add the route picker to a temporary window to ensure it's in the view hierarchy + UIWindow* window = [[UIApplication sharedApplication] keyWindow]; + if (!window) { + // Fallback for iOS 13+ where keyWindow is deprecated + for (UIWindowScene* windowScene in [UIApplication sharedApplication].connectedScenes) { + if (windowScene.activationState == UISceneActivationStateForegroundActive) { + window = windowScene.windows.firstObject; + break; + } + } + } + + if (window) { + [window addSubview:routePicker]; + + // Trigger the route picker programmatically + for (UIView* view in routePicker.subviews) { + if ([view isKindOfClass:[UIButton class]]) { + UIButton* button = (UIButton*)view; + [button sendActionsForControlEvents:UIControlEventTouchUpInside]; + break; // Only trigger the first button found + } + } + + // Remove the route picker after a short delay + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [routePicker removeFromSuperview]; + }); + + result(nil); + } else { + result([FlutterError errorWithCode:@"NoWindowError" + message:@"Could not find a window to present the route picker" + details:nil]); + } + } else { + result([FlutterError errorWithCode:@"UnsupportedVersionError" + message:@"AVRoutePickerView is only available on iOS 11.0 or later" + details:nil]); + } +#else // macOS doesn't support iOS audio route selection UI result([FlutterError errorWithCode:@"UnsupportedPlatformError" message:@"triggeriOSAudioRouteSelectionUI is only supported on iOS" details:nil]); +#endif } - (void)mediaStreamTrackRelease:(RTCMediaStream*)mediaStream track:(RTCMediaStreamTrack*)track { @@ -968,7 +1126,35 @@ - (void)mediaStreamTrackSetTorch:(RTCMediaStreamTrack*)track - (void)mediaStreamTrackSetZoom:(RTCMediaStreamTrack*)track zoomLevel:(double)zoomLevel result:(FlutterResult)result { +#if TARGET_OS_OSX NSLog(@"Not supported on macOS. Can't set zoom"); + return; +#endif +#if TARGET_OS_IPHONE + if (!self.videoCapturer) { + NSLog(@"Video capturer is null. Can't set zoom"); + return; + } + if (self.videoCapturer.captureSession.inputs.count == 0) { + NSLog(@"Video capturer is missing an input. Can't set zoom"); + return; + } + + AVCaptureDeviceInput* deviceInput = [self.videoCapturer.captureSession.inputs objectAtIndex:0]; + AVCaptureDevice* device = deviceInput.device; + + NSError* error; + if ([device lockForConfiguration:&error] == NO) { + NSLog(@"Failed to acquire configuration lock. %@", error.localizedDescription); + return; + } + + CGFloat desiredZoomFactor = (CGFloat)zoomLevel; + device.videoZoomFactor = MAX(1.0, MIN(desiredZoomFactor, device.activeFormat.videoMaxZoomFactor)); + [device unlockForConfiguration]; + + result(nil); +#endif } - (void)mediaStreamTrackCaptureFrame:(RTCVideoTrack*)track @@ -1012,6 +1198,13 @@ - (AVCaptureDeviceFormat*)selectFormatForDevice:(AVCaptureDevice*)device for (AVCaptureDeviceFormat* format in formats) { CMVideoDimensions dimension = CMVideoFormatDescriptionGetDimensions(format.formatDescription); FourCharCode pixelFormat = CMFormatDescriptionGetMediaSubType(format.formatDescription); +#if TARGET_OS_IPHONE + if (@available(iOS 13.0, *)) { + if (format.isMultiCamSupported != AVCaptureMultiCamSession.multiCamSupported) { + continue; + } + } +#endif // NSLog(@"AVCaptureDeviceFormats,fps %d, dimension: %dx%d", // format.videoSupportedFrameRateRanges, dimension.width, dimension.height); long diff = labs(targetWidth - dimension.width) + labs(targetHeight - dimension.height); diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m index e7fec46cbb..c58e7cfd82 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m @@ -1,4 +1,7 @@ #import "include/stream_webrtc_flutter/FlutterWebRTCPlugin.h" +#if TARGET_OS_IPHONE +#import "include/stream_webrtc_flutter/AudioUtils.h" +#endif #import "include/stream_webrtc_flutter/CameraUtils.h" #import "include/stream_webrtc_flutter/AudioManager.h" @@ -8,6 +11,11 @@ #import "include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h" #import "include/stream_webrtc_flutter/FlutterRTCMediaStream.h" #import "include/stream_webrtc_flutter/FlutterRTCPeerConnection.h" +#if TARGET_OS_IPHONE +#import "include/stream_webrtc_flutter/FlutterRTCMediaRecorder.h" +#import "include/stream_webrtc_flutter/FlutterRTCVideoPlatformViewController.h" +#import "include/stream_webrtc_flutter/FlutterRTCVideoPlatformViewFactory.h" +#endif #import "include/stream_webrtc_flutter/FlutterRTCVideoRenderer.h" #import "include/stream_webrtc_flutter/ProcessorProvider.h" #import "include/stream_webrtc_flutter/VideoEffectProcessor.h" @@ -117,8 +125,15 @@ @implementation FlutterWebRTCPlugin { id _textures; BOOL _speakerOn; BOOL _speakerOnButPreferBluetooth; +#if TARGET_OS_IPHONE + AVAudioSessionPort _preferredInput; +#endif AudioManager* _audioManager; BOOL _stereoPlayoutPreferred; +#if TARGET_OS_IPHONE + FLutterRTCVideoPlatformViewFactory* _platformViewFactory; + dispatch_block_t _stereoRefreshDebounceBlock; +#endif RTC_OBJC_TYPE(RTCCallbackLogger) * loggerCallback; @@ -136,16 +151,25 @@ + (FlutterWebRTCPlugin*)sharedSingleton { @synthesize messenger = _messenger; @synthesize eventSink = _eventSink; +#if TARGET_OS_IPHONE +@synthesize preferredInput = _preferredInput; +#endif @synthesize audioManager = _audioManager; + (void)registerWithRegistrar:(NSObject*)registrar { FlutterMethodChannel* channel = [FlutterMethodChannel methodChannelWithName:@"FlutterWebRTC.Method" binaryMessenger:[registrar messenger]]; +#if TARGET_OS_IPHONE + UIViewController* viewController = (UIViewController*)registrar.messenger; +#endif FlutterWebRTCPlugin* instance = [[FlutterWebRTCPlugin alloc] initWithChannel:channel registrar:registrar messenger:[registrar messenger] +#if TARGET_OS_IPHONE + viewController:viewController +#endif withTextures:[registrar textures]]; [registrar addMethodCallDelegate:instance channel:channel]; } @@ -153,7 +177,11 @@ + (void)registerWithRegistrar:(NSObject*)registrar { - (instancetype)initWithChannel:(FlutterMethodChannel*)channel registrar:(NSObject*)registrar messenger:(NSObject*)messenger +#if TARGET_OS_IPHONE + viewController:(UIViewController*)viewController +#endif withTextures:(NSObject*)textures { + self = [super init]; sharedSingleton = self; @@ -170,6 +198,14 @@ - (instancetype)initWithChannel:(FlutterMethodChannel*)channel _speakerOnButPreferBluetooth = NO; _eventChannel = eventChannel; _audioManager = AudioManager.sharedInstance; + +#if TARGET_OS_IPHONE + _preferredInput = AVAudioSessionPortHeadphones; + self.viewController = viewController; + _platformViewFactory = [[FLutterRTCVideoPlatformViewFactory alloc] initWithMessenger:messenger]; + [registrar registerViewFactory:_platformViewFactory + withId:FLutterRTCVideoPlatformViewFactoryID]; +#endif } NSDictionary* fieldTrials = @{kRTCFieldTrialUseNWPathMonitor : kRTCFieldTrialEnabledValue}; @@ -181,12 +217,24 @@ - (instancetype)initWithChannel:(FlutterMethodChannel*)channel self.renders = [NSMutableDictionary new]; self.videoCapturerStopHandlers = [NSMutableDictionary new]; self.videoCaptureState = [NSMutableDictionary new]; +#if TARGET_OS_IPHONE + self.recorders = [NSMutableDictionary new]; +#endif self.trackVolumeCache = [NSMutableDictionary new]; self.pausedTrackVolumes = [NSMutableDictionary new]; _factories = [NSMutableDictionary new]; _pcFactoryId = [NSMutableDictionary new]; _trackFactoryId = [NSMutableDictionary new]; self.isAudioPlayoutPaused = NO; +#if TARGET_OS_IPHONE + self.focusMode = @"locked"; + self.exposureMode = @"locked"; + AVAudioSession* session = [AVAudioSession sharedInstance]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(didSessionRouteChange:) + name:AVAudioSessionRouteChangeNotification + object:session]; +#endif return self; } @@ -219,9 +267,80 @@ - (FlutterError* _Nullable)onListenWithArguments:(id _Nullable)arguments } - (void)didSessionRouteChange:(NSNotification*)notification { +#if TARGET_OS_IPHONE + NSDictionary* interuptionDict = notification.userInfo; + NSInteger routeChangeReason = + [[interuptionDict valueForKey:AVAudioSessionRouteChangeReasonKey] integerValue]; + if (self.eventSink && (routeChangeReason == AVAudioSessionRouteChangeReasonNewDeviceAvailable || + routeChangeReason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable || + routeChangeReason == AVAudioSessionRouteChangeReasonCategoryChange || + routeChangeReason == AVAudioSessionRouteChangeReasonOverride)) { + postEvent(self.eventSink, @{@"event" : @"onDeviceChange"}); + + // Report the newly-active audio output + AVAudioSessionPortDescription* output = + [RTCAudioSession sharedInstance].currentRoute.outputs.firstObject; + if (output != nil) { + postEvent(self.eventSink, @{ + @"event" : @"onAudioRouteChange", + @"deviceId" : output.UID ?: @"", + @"label" : output.portName ?: @"", + @"groupId" : output.portType ?: @"", + }); + } + } + + // When stereo playout is preferred, debounce-refresh stereo playout state + // after route changes so stereo is re-evaluated for the new audio device. + if (_stereoPlayoutPreferred) { + // Cancel any previously scheduled debounce block + if (_stereoRefreshDebounceBlock != nil) { + dispatch_block_cancel(_stereoRefreshDebounceBlock); + _stereoRefreshDebounceBlock = nil; + } + + __weak typeof(self) weakSelf = self; + _stereoRefreshDebounceBlock = dispatch_block_create(0, ^{ + __strong typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf == nil) + return; + // Refresh every active factory's ADM so the new route is re-evaluated + NSLog(@"FlutterWebRTCPlugin: refreshing stereo playout state on all factories"); + NSArray* snapshot; + @synchronized(strongSelf) { + snapshot = [strongSelf.factories.allValues copy]; + } + for (NativePeerConnectionFactory* nf in snapshot) { + RTCAudioDeviceModule* adm = nf.audioDeviceModule; + if (adm != nil) { + // Run on the factory's serial ADM queue + dispatch_async(nf.admQueue, ^{ + [adm refreshStereoPlayoutState]; + }); + } + } + strongSelf->_stereoRefreshDebounceBlock = nil; + }); + + // Debounce for 2 seconds (matching Swift SDK behavior) + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), + dispatch_get_main_queue(), _stereoRefreshDebounceBlock); + } +#endif } - (void)handleInterruption:(NSNotification*)notification { +#if TARGET_OS_IPHONE + NSDictionary* info = notification.userInfo; + AVAudioSessionInterruptionType type = + [info[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue]; + + if (type == AVAudioSessionInterruptionTypeBegan) { + postEvent(self.eventSink, @{@"event" : @"onInterruptionStart"}); + } else if (type == AVAudioSessionInterruptionTypeEnded) { + postEvent(self.eventSink, @{@"event" : @"onInterruptionEnd"}); + } +#endif } - (void)initLoggerCallback:(RTCLoggingSeverity)severity { @@ -317,6 +436,13 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { @synchronized(self) { _factories[factoryId] = nf; } +#if TARGET_OS_IPHONE + // Replay any process-wide ADM prefs that were set before this factory + // existed (notably the stereo-playout preference set by the SDK during + // StreamVideo init). Without this, per-call factories created later + // miss the stereo-playout settings and incoming Opus stereo plays mono. + [self applyPersistentAdmStateToFactory:nf]; +#endif NSLog(@"[createPeerConnectionFactory] built id: %@", factoryId); result(@{@"factoryId" : factoryId}); } else if ([@"disposePeerConnectionFactory" isEqualToString:call.method]) { @@ -406,6 +532,12 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { [self mediaStreamTrackSetVideoEffects:trackId names:names]; result(nil); } else if ([@"handleCallInterruptionCallbacks" isEqualToString:call.method]) { +#if TARGET_OS_IPHONE + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleInterruption:) + name:AVAudioSessionInterruptionNotification + object:[AVAudioSession sharedInstance]]; +#endif result(@""); } else if ([@"createPeerConnection" isEqualToString:call.method]) { NSDictionary* argsMap = call.arguments; @@ -970,7 +1102,55 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } [self rendererSetSrcObject:render stream:videoTrack]; result(nil); - } else if ([@"enableIOSMultitaskingCameraAccess" isEqualToString:call.method]) { + } +#if TARGET_OS_IPHONE + else if ([@"videoPlatformViewRendererSetSrcObject" isEqualToString:call.method]) { + NSDictionary* argsMap = call.arguments; + NSNumber* viewId = argsMap[@"viewId"]; + FlutterRTCVideoPlatformViewController* render = _platformViewFactory.renders[viewId]; + NSString* streamId = argsMap[@"streamId"]; + NSString* ownerTag = argsMap[@"ownerTag"]; + NSString* trackId = argsMap[@"trackId"]; + if (!render) { + result([FlutterError errorWithCode:@"videoRendererSetSrcObject: render is nil" + message:nil + details:nil]); + return; + } + RTCMediaStream* stream = nil; + RTCVideoTrack* videoTrack = nil; + if ([ownerTag isEqualToString:@"local"]) { + stream = _localStreams[streamId]; + } + if (!stream) { + stream = [self streamForId:streamId peerConnectionId:ownerTag]; + } + if (stream) { + NSArray* videoTracks = stream ? stream.videoTracks : nil; + videoTrack = videoTracks && videoTracks.count ? videoTracks[0] : nil; + for (RTCVideoTrack* track in videoTracks) { + if ([track.trackId isEqualToString:trackId]) { + videoTrack = track; + } + } + if (!videoTrack) { + NSLog(@"Not found video track for RTCMediaStream: %@", streamId); + } + } + render.videoTrack = videoTrack; + result(nil); + } else if ([@"videoPlatformViewRendererDispose" isEqualToString:call.method]) { + NSDictionary* argsMap = call.arguments; + NSNumber* viewId = argsMap[@"viewId"]; + FlutterRTCVideoPlatformViewController* render = _platformViewFactory.renders[viewId]; + if (render != nil) { + render.videoTrack = nil; + [_platformViewFactory.renders removeObjectForKey:viewId]; + } + result(nil); + } +#endif + else if ([@"enableIOSMultitaskingCameraAccess" isEqualToString:call.method]) { NSDictionary* argsMap = call.arguments; BOOL enable = [argsMap[@"enable"] boolValue]; @@ -1158,7 +1338,32 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { audioTrack.isEnabled = !mute.boolValue; } result(nil); - } else if ([@"getLocalDescription" isEqualToString:call.method]) { + } +#if TARGET_OS_IPHONE + else if ([@"enableSpeakerphone" isEqualToString:call.method]) { + NSDictionary* argsMap = call.arguments; + NSNumber* enable = argsMap[@"enable"]; + _speakerOn = enable.boolValue; + _speakerOnButPreferBluetooth = NO; + [AudioUtils setSpeakerphoneOn:_speakerOn]; + postEvent(self.eventSink, @{@"event" : @"onDeviceChange"}); + result(nil); + } else if ([@"ensureAudioSession" isEqualToString:call.method]) { + [self ensureAudioSession]; + result(nil); + } else if ([@"enableSpeakerphoneButPreferBluetooth" isEqualToString:call.method]) { + _speakerOn = YES; + _speakerOnButPreferBluetooth = YES; + [AudioUtils setSpeakerphoneOnButPreferBluetooth]; + result(nil); + } else if ([@"setAppleAudioConfiguration" isEqualToString:call.method]) { + NSDictionary* argsMap = call.arguments; + NSDictionary* configuration = argsMap[@"configuration"]; + [AudioUtils setAppleAudioConfiguration:configuration]; + result(nil); + } +#endif + else if ([@"getLocalDescription" isEqualToString:call.method]) { NSDictionary* argsMap = call.arguments; NSString* peerConnectionId = argsMap[@"peerConnectionId"]; RTCPeerConnection* peerConnection = self.peerConnections[peerConnectionId]; @@ -1625,6 +1830,41 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { NSString* severityStr = argsMap[@"severity"]; RTCLoggingSeverity severity = [self str2LogSeverity:severityStr]; [self initLoggerCallback:severity]; +#if TARGET_OS_IOS + } else if ([@"startRecordToFile" isEqualToString:call.method]) { + NSDictionary* argsMap = call.arguments; + NSNumber* recorderId = argsMap[@"recorderId"]; + NSString* path = argsMap[@"path"]; + NSString* trackId = argsMap[@"videoTrackId"]; + NSString* peerConnectionId = argsMap[@"peerConnectionId"]; + NSString* audioTrackId = [self audioTrackIdForVideoTrackId:trackId]; + + RTCMediaStreamTrack* track = [self trackForId:trackId peerConnectionId:peerConnectionId]; + RTCMediaStreamTrack* audioTrack = [self trackForId:audioTrackId + peerConnectionId:peerConnectionId]; + if (track != nil && [track isKindOfClass:[RTCVideoTrack class]]) { + NSURL* pathUrl = [NSURL fileURLWithPath:path]; + self.recorders[recorderId] = + [[FlutterRTCMediaRecorder alloc] initWithVideoTrack:(RTCVideoTrack*)track + audioTrack:(RTCAudioTrack*)audioTrack + outputFile:pathUrl]; + } + result(nil); + } else if ([@"stopRecordToFile" isEqualToString:call.method]) { + NSDictionary* argsMap = call.arguments; + NSNumber* recorderId = argsMap[@"recorderId"]; + FlutterRTCMediaRecorder* recorder = self.recorders[recorderId]; + if (recorder != nil) { + [recorder stop:result]; + [self.recorders removeObjectForKey:recorderId]; + } else { + result([FlutterError + errorWithCode:[NSString stringWithFormat:@"%@ failed", call.method] + message:[NSString + stringWithFormat:@"Error: recorder with id %@ not found!", recorderId] + details:nil]); + } +#endif } else if ([@"resumeAudioPlayout" isEqualToString:call.method]) { self.isAudioPlayoutPaused = NO; @@ -1880,6 +2120,12 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } dispatch_async(nf.admQueue, ^{ if (nf.audioSuspended) { +#if TARGET_OS_IPHONE + NSDictionary* audioConfigSnapshot = nf.audioConfigSnapshot; + if (audioConfigSnapshot != nil) { + [AudioUtils setAppleAudioConfiguration:audioConfigSnapshot]; + } +#endif BOOL restorePlaying = nf.wasPlayingBeforeSuspend; BOOL restoreRecording = nf.wasRecordingBeforeSuspend; if (restorePlaying) { @@ -2113,10 +2359,42 @@ - (NativePeerConnectionFactory*)resolveFactoryForStreamId:(NSString*)streamId { return nil; } +#if TARGET_OS_IPHONE +/** + * Replays process-wide ADM state (stereo playout preference, mute mode, + * voice-processing bypass) onto a freshly-built factory's ADM. + */ +- (void)applyPersistentAdmStateToFactory:(NativePeerConnectionFactory*)factory { + if (!_stereoPlayoutPreferred) { + return; + } + RTCAudioDeviceModule* adm = factory.audioDeviceModule; + if (adm == nil) { + return; + } + // Serialize with the factory's ADM queue like every other ADM mutation. The + // queue is empty at this point — the factory was just built and its id has + // not reached Dart yet — so this does not block meaningfully. + dispatch_sync(factory.admQueue, ^{ + adm.prefersStereoPlayout = YES; + adm.voiceProcessingBypassed = YES; + [adm setMuteMode:RTCAudioEngineMuteModeInputMixer]; + }); +} +#endif + - (void)ensureAudioSession { +#if TARGET_OS_IPHONE + [AudioUtils ensureAudioSessionWithRecording:[self hasLocalAudioTrack]]; +#endif } - (void)deactiveRtcAudioSession { +#if TARGET_OS_IPHONE + if (![self hasLocalAudioTrack] && self.peerConnections.count == 0) { + [AudioUtils deactiveRtcAudioSession]; + } +#endif } - (void)mediaStreamTrackSetVideoEffects:(nonnull NSString*)trackId @@ -2147,8 +2425,50 @@ - (void)mediaStreamTrackSetVideoEffects:(nonnull NSString*)trackId } - (void)enableMultitaskingCameraAccess:(BOOL)enable result:(FlutterResult)result { +#if TARGET_OS_OSX NSLog(@"enableMultitaskingCameraAccess: Multitasking camera access is not available on macOS."); result(@NO); +#else + @try { + AVCaptureSession* session = self.videoCapturer.captureSession; + if (session == nil) { + NSLog(@"enableMultitaskingCameraAccess: Capture session is nil."); + result(@NO); + return; + } + + if (@available(iOS 16.0, *)) { + BOOL shouldChange = session.multitaskingCameraAccessEnabled != enable; + BOOL canChange = !enable || (enable && session.isMultitaskingCameraAccessSupported); + + if (shouldChange && canChange) { + [session beginConfiguration]; + [session setMultitaskingCameraAccessEnabled:enable]; + [session commitConfiguration]; + + result(enable ? @YES : @NO); + } else { + if (!canChange) { + NSLog(@"enableMultitaskingCameraAccess: Multitasking camera access is not supported on " + @"this device."); + result(@NO); + } else { + NSLog(@"enableMultitaskingCameraAccess: Multitasking camera access is already %@.", + enable ? @"enabled" : @"disabled"); + result(enable ? @YES : @NO); + } + } + } else { + NSLog( + @"enableMultitaskingCameraAccess: Multitasking camera access requires iOS 16 or later."); + result(@NO); + } + } @catch (NSException* exception) { + NSLog(@"enableMultitaskingCameraAccess: Exception occurred: %@ - %@", exception.name, + exception.reason); + result(@NO); + } +#endif } - (void)mediaStreamGetTracks:(NSString*)streamId result:(FlutterResult)result { @@ -2766,6 +3086,10 @@ - (RTCRtpEncodingParameters*)mapToEncoding:(NSDictionary*)map { encoding.isActive = YES; encoding.scaleResolutionDownBy = [NSNumber numberWithDouble:1.0]; encoding.numTemporalLayers = [NSNumber numberWithInt:1]; +#if TARGET_OS_IPHONE + encoding.networkPriority = RTCPriorityLow; + encoding.bitratePriority = 1.0; +#endif [encoding setRid:map[@"rid"]]; if (map[@"active"] != nil) { diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m index 211a13ac10..353ab13329 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m @@ -1,4 +1,7 @@ #import "include/stream_webrtc_flutter/NativePeerConnectionFactory.h" +#if TARGET_OS_IPHONE +#import "include/stream_webrtc_flutter/AudioUtils.h" +#endif #import "include/stream_webrtc_flutter/VideoFactoriesPrivate.h" #import @@ -29,6 +32,12 @@ - (instancetype)initWithFactoryId:(NSString*)factoryId dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, 0)); +#if TARGET_OS_IPHONE + if (appleAudioConfiguration != nil) { + [AudioUtils setAppleAudioConfiguration:appleAudioConfiguration]; + } +#endif + VideoDecoderFactory* decoderFactory = [[VideoDecoderFactory alloc] init]; VideoEncoderFactory* encoderFactory = [[VideoEncoderFactory alloc] init]; VideoEncoderFactorySimulcast* simulcastFactory = diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterDataPacketCryptor.h b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterDataPacketCryptor.h index b36847b54b..0ef0755c3c 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterDataPacketCryptor.h +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterDataPacketCryptor.h @@ -1,4 +1,8 @@ +#if TARGET_OS_IPHONE +#import +#else #import +#endif #import #import "FlutterWebRTCPlugin.h" diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCDesktopCapturer.h b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCDesktopCapturer.h index f40bc328d8..771dfbf06b 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCDesktopCapturer.h +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCDesktopCapturer.h @@ -1,4 +1,8 @@ +#if TARGET_OS_IPHONE +#import +#else #import +#endif #import #import diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h index caa2da126c..d41d929448 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h @@ -1,4 +1,8 @@ +#if TARGET_OS_IPHONE +#import +#else #import +#endif #import #import "FlutterWebRTCPlugin.h" diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCFrameCapturer.h b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCFrameCapturer.h index 4f9f6bc2dc..b5c06e2a0d 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCFrameCapturer.h +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCFrameCapturer.h @@ -1,4 +1,8 @@ +#if TARGET_OS_IPHONE +#import +#else #import +#endif #import @interface FlutterRTCFrameCapturer : NSObject diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterWebRTCPlugin.h b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterWebRTCPlugin.h index 97711aafbb..b10d3cf0a1 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterWebRTCPlugin.h +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterWebRTCPlugin.h @@ -1,11 +1,18 @@ -#import +#if TARGET_OS_IPHONE +#import +#else #import +#endif +#import #import #import "LocalTrack.h" @class VideoEffectProcessor; @class FlutterRTCVideoRenderer; @class FlutterRTCFrameCapturer; +#if TARGET_OS_IPHONE +@class FlutterRTCMediaRecorder; +#endif @class AudioManager; @class NativePeerConnectionFactory; @@ -18,9 +25,11 @@ typedef void (^CapturerStopHandler)(CompletionHandler _Nonnull handler); @interface FlutterWebRTCPlugin : NSObject + RTCDesktopCapturerDelegate, +#endif + FlutterStreamHandler> /** * Per-call factory registry, keyed by factoryId. @@ -50,15 +59,27 @@ typedef void (^CapturerStopHandler)(CompletionHandler _Nonnull handler); @property(nonatomic, strong) NSMutableDictionary>* _Nullable localTracks; @property(nonatomic, strong) NSMutableDictionary* _Nullable renders; +#if TARGET_OS_IPHONE +@property(nonatomic, strong) + NSMutableDictionary* _Nonnull recorders; +#endif @property(nonatomic, strong) NSMutableDictionary* _Nullable videoCapturerStopHandlers; @property(nonatomic, strong) NSMutableDictionary* _Nullable videoCaptureState; +#if TARGET_OS_IPHONE +@property(nonatomic, retain) + UIViewController* _Nullable viewController; /*for broadcast or ReplayKit */ +#endif + @property(nonatomic, strong) FlutterEventSink _Nullable eventSink; @property(nonatomic, strong) NSObject* _Nonnull messenger; @property(nonatomic, strong) RTCCameraVideoCapturer* _Nullable videoCapturer; @property(nonatomic, strong) FlutterRTCFrameCapturer* _Nullable frameCapturer; +#if TARGET_OS_IPHONE +@property(nonatomic, strong) AVAudioSessionPort _Nullable preferredInput; +#endif @property(nonatomic, strong) VideoEffectProcessor* _Nullable videoEffectProcessor; @property(nonatomic, strong) NSString* _Nonnull focusMode; diff --git a/test/unit/darwin_parity_test.dart b/test/unit/darwin_parity_test.dart new file mode 100644 index 0000000000..8b4f924cf1 --- /dev/null +++ b/test/unit/darwin_parity_test.dart @@ -0,0 +1,19 @@ +@TestOn('vm') +library; + +import 'package:flutter_test/flutter_test.dart'; + +import '../../tool/darwin_parity.dart'; + +void main() { + test('iOS and macOS Objective-C trees agree', () { + final findings = checkDarwinParity(); + expect( + findings, + isEmpty, + reason: 'The two Darwin source trees have drifted:\n\n' + '${findings.map((f) => ' - $f').join('\n\n')}\n\n' + 'See tool/darwin_parity.dart for how the trees are meant to relate.', + ); + }); +} diff --git a/tool/darwin_parity.dart b/tool/darwin_parity.dart new file mode 100644 index 0000000000..0c7160028c --- /dev/null +++ b/tool/darwin_parity.dart @@ -0,0 +1,265 @@ +// Keeps the iOS and macOS Objective-C trees honest. +// +// SPM requires sources to live under the target path and will not follow +// symlinks, so the Darwin plugin code exists twice: +// +// ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/ +// macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/ +// +// Every file that exists in both trees is byte-identical: platform differences +// live inside `#if TARGET_OS_IPHONE` / `#if TARGET_OS_OSX` guards rather than in +// separate copies of the file. A file that genuinely belongs to one platform +// only exists in one tree and is declared in [iosOnly] or [macosOnly] below. +// +// That makes the check a byte comparison, with no normalization and nothing to +// keep in sync by hand beyond the two lists. +// +// Usage: +// dart tool/darwin_parity.dart # check, exit non-zero on findings +// dart tool/darwin_parity.dart check +// dart tool/darwin_parity.dart fix --to=macos +// dart tool/darwin_parity.dart fix --to=ios +// +// `test/unit/darwin_parity_test.dart` runs the same check, so `flutter test` +// (already wired into CI) fails on drift. + +import 'dart:io'; + +const iosTree = 'ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter'; +const macosTree = 'macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter'; + +/// Files that exist only in the iOS tree, as paths relative to [iosTree]. +/// +/// Adding a Darwin source file means either giving it a counterpart in the +/// other tree or naming it here. +const iosOnly = { + 'AudioUtils.m', + 'Broadcast/FlutterBroadcastScreenCapturer.m', + 'Broadcast/FlutterSocketConnection.m', + 'Broadcast/FlutterSocketConnectionFrameReader.m', + 'FlutterRPScreenRecorder.m', + 'FlutterRTCMediaRecorder.m', + 'FlutterRTCVideoPlatformView.m', + 'FlutterRTCVideoPlatformViewController.m', + 'FlutterRTCVideoPlatformViewFactory.m', + 'include/stream_webrtc_flutter/AudioUtils.h', + 'include/stream_webrtc_flutter/Broadcast/FlutterBroadcastScreenCapturer.h', + 'include/stream_webrtc_flutter/Broadcast/FlutterSocketConnection.h', + 'include/stream_webrtc_flutter/Broadcast/FlutterSocketConnectionFrameReader.h', + 'include/stream_webrtc_flutter/FlutterRPScreenRecorder.h', + 'include/stream_webrtc_flutter/FlutterRTCMediaRecorder.h', + 'include/stream_webrtc_flutter/FlutterRTCVideoPlatformView.h', + 'include/stream_webrtc_flutter/FlutterRTCVideoPlatformViewController.h', + 'include/stream_webrtc_flutter/FlutterRTCVideoPlatformViewFactory.h', +}; + +/// Files that exist only in the macOS tree, as paths relative to [macosTree]. +const macosOnly = { + 'StreamMacAudioDevices.m', + 'include/stream_webrtc_flutter/StreamMacAudioDevices.h', +}; + +/// Walks up from [start] until it finds the directory holding `pubspec.yaml`. +Directory findRepoRoot([Directory? start]) { + var dir = start ?? Directory.current; + while (true) { + if (File('${dir.path}/pubspec.yaml').existsSync()) return dir; + final parent = dir.parent; + if (parent.path == dir.path) { + throw StateError('could not find pubspec.yaml above ${Directory.current.path}'); + } + dir = parent; + } +} + +Set _filesUnder(Directory root) { + if (!root.existsSync()) { + throw StateError('missing Darwin source tree: ${root.path}'); + } + final prefix = '${root.path}/'; + return root + .listSync(recursive: true) + .whereType() + .map((f) => f.path.substring(prefix.length)) + .where((p) => !p.split('/').any((seg) => seg.startsWith('.'))) + .toSet(); +} + +/// Every way the two trees can disagree, as human-readable findings. +/// +/// An empty list means the trees are in the state this tool exists to hold them +/// in: shared files byte-identical, one-sided files declared. +List checkDarwinParity({Directory? repoRoot}) { + final root = (repoRoot ?? findRepoRoot()).path; + final iosRoot = Directory('$root/$iosTree'); + final macosRoot = Directory('$root/$macosTree'); + + final ios = _filesUnder(iosRoot); + final macos = _filesUnder(macosRoot); + final findings = []; + + // 1. Shared files must be byte-identical. + for (final path in (ios.intersection(macos)).toList()..sort()) { + final a = File('${iosRoot.path}/$path').readAsBytesSync(); + final b = File('${macosRoot.path}/$path').readAsBytesSync(); + var same = a.length == b.length; + if (same) { + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + same = false; + break; + } + } + } + if (!same) { + findings.add( + '$path differs between the two trees.\n' + ' A change landed in one copy only. Fold the platform difference into a\n' + " `#if TARGET_OS_IPHONE` / `#if TARGET_OS_OSX` guard, then run\n" + ' `dart tool/darwin_parity.dart fix --to=macos` (or --to=ios) to copy the\n' + ' reconciled file across.', + ); + } + } + + // 2. A file in one tree only must say so. + for (final path in (ios.difference(macos)).toList()..sort()) { + if (!iosOnly.contains(path)) { + findings.add( + '$path exists in the iOS tree but not the macOS tree, and is not declared.\n' + ' Either add its macOS counterpart (`fix --to=macos` copies it) or add it\n' + ' to `iosOnly` in tool/darwin_parity.dart.', + ); + } + } + for (final path in (macos.difference(ios)).toList()..sort()) { + if (!macosOnly.contains(path)) { + findings.add( + '$path exists in the macOS tree but not the iOS tree, and is not declared.\n' + ' Either add its iOS counterpart (`fix --to=ios` copies it) or add it to\n' + ' `macosOnly` in tool/darwin_parity.dart.', + ); + } + } + + // 3. The declarations themselves must still be true. + for (final path in (iosOnly.toList()..sort())) { + if (!ios.contains(path)) { + findings.add( + "$path is declared iOS-only but no longer exists in the iOS tree.\n" + ' Remove it from `iosOnly` in tool/darwin_parity.dart.', + ); + } else if (macos.contains(path)) { + findings.add( + '$path is declared iOS-only but now exists in the macOS tree too.\n' + ' Remove it from `iosOnly` so the two copies are compared.', + ); + } + } + for (final path in (macosOnly.toList()..sort())) { + if (!macos.contains(path)) { + findings.add( + '$path is declared macOS-only but no longer exists in the macOS tree.\n' + ' Remove it from `macosOnly` in tool/darwin_parity.dart.', + ); + } else if (ios.contains(path)) { + findings.add( + '$path is declared macOS-only but now exists in the iOS tree too.\n' + ' Remove it from `macosOnly` so the two copies are compared.', + ); + } + } + + return findings; +} + +/// Copies shared files onto [to], which is either `ios` or `macos`. +/// +/// Direction is always explicit and never inferred, so the tool cannot guess +/// wrong about which side moved. Files declared one-sided are left alone; an +/// undeclared file with no counterpart gets one, since "shared" is the only +/// remaining reading of a file nobody declared. +List fixDarwinParity({required String to, Directory? repoRoot}) { + if (to != 'ios' && to != 'macos') { + throw ArgumentError.value(to, 'to', 'expected "ios" or "macos"'); + } + final root = (repoRoot ?? findRepoRoot()).path; + final iosRoot = Directory('$root/$iosTree'); + final macosRoot = Directory('$root/$macosTree'); + + final fromRoot = to == 'macos' ? iosRoot : macosRoot; + final toRoot = to == 'macos' ? macosRoot : iosRoot; + final skip = to == 'macos' ? iosOnly : macosOnly; + + final changed = []; + for (final path in (_filesUnder(fromRoot).toList()..sort())) { + if (skip.contains(path)) continue; + final src = File('${fromRoot.path}/$path'); + final dst = File('${toRoot.path}/$path'); + if (dst.existsSync()) { + final a = src.readAsBytesSync(); + final b = dst.readAsBytesSync(); + if (a.length == b.length) { + var same = true; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + same = false; + break; + } + } + if (same) continue; + } + } else { + dst.parent.createSync(recursive: true); + } + src.copySync(dst.path); + changed.add(path); + } + return changed; +} + +void main(List args) { + final command = args.isEmpty ? 'check' : args.first; + + if (command == 'check') { + final findings = checkDarwinParity(); + if (findings.isEmpty) { + stdout.writeln('darwin parity: iOS and macOS trees agree.'); + return; + } + stderr.writeln('darwin parity: ${findings.length} finding(s).\n'); + for (final finding in findings) { + stderr.writeln(' - $finding\n'); + } + exitCode = 1; + return; + } + + if (command == 'fix') { + String? to; + for (final arg in args.skip(1)) { + if (arg.startsWith('--to=')) to = arg.substring('--to='.length); + } + if (to == null) { + stderr.writeln( + 'fix needs an explicit direction: --to=macos or --to=ios.\n' + 'It is never inferred, so it cannot guess wrong about which side moved.', + ); + exitCode = 2; + return; + } + final changed = fixDarwinParity(to: to); + if (changed.isEmpty) { + stdout.writeln('darwin parity: nothing to copy to $to.'); + return; + } + stdout.writeln('darwin parity: copied ${changed.length} file(s) to $to:'); + for (final path in changed) { + stdout.writeln(' $path'); + } + return; + } + + stderr.writeln('usage: dart tool/darwin_parity.dart [check | fix --to=macos|ios]'); + exitCode = 2; +}