diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 127da4561bc3..f0d60cbb0df0 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.10.1 + +* Implements option to also stream when recording a video. + ## 0.10.0+4 * Removes usage of `_ambiguate` method in example. diff --git a/packages/camera/camera/lib/src/camera_controller.dart b/packages/camera/camera/lib/src/camera_controller.dart index ed1c951925d8..9518d306a658 100644 --- a/packages/camera/camera/lib/src/camera_controller.dart +++ b/packages/camera/camera/lib/src/camera_controller.dart @@ -459,12 +459,6 @@ class CameraController extends ValueNotifier { assert(defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS); _throwIfNotInitialized('stopImageStream'); - if (value.isRecordingVideo) { - throw CameraException( - 'A video recording is already started.', - 'stopImageStream was called while a video is being recorded.', - ); - } if (!value.isStreamingImages) { throw CameraException( 'No camera is streaming images', @@ -483,9 +477,13 @@ class CameraController extends ValueNotifier { /// Start a video recording. /// + /// You may optionally pass an [onAvailable] callback to also have the + /// video frames streamed to this callback. + /// /// The video is returned as a [XFile] after calling [stopVideoRecording]. /// Throws a [CameraException] if the capture fails. - Future startVideoRecording() async { + Future startVideoRecording( + {onLatestImageAvailable? onAvailable}) async { _throwIfNotInitialized('startVideoRecording'); if (value.isRecordingVideo) { throw CameraException( @@ -493,18 +491,21 @@ class CameraController extends ValueNotifier { 'startVideoRecording was called when a recording is already started.', ); } - if (value.isStreamingImages) { - throw CameraException( - 'A camera has started streaming images.', - 'startVideoRecording was called while a camera was streaming images.', - ); + + Function(CameraImageData image)? streamCallback; + if (onAvailable != null) { + streamCallback = (CameraImageData imageData) { + onAvailable(CameraImage.fromPlatformInterface(imageData)); + }; } try { - await CameraPlatform.instance.startVideoRecording(_cameraId); + await CameraPlatform.instance.startVideoCapturing( + VideoCaptureOptions(_cameraId, streamCallback: streamCallback)); value = value.copyWith( isRecordingVideo: true, isRecordingPaused: false, + isStreamingImages: onAvailable != null, recordingOrientation: Optional.of( value.lockedCaptureOrientation ?? value.deviceOrientation)); } on PlatformException catch (e) { @@ -523,6 +524,11 @@ class CameraController extends ValueNotifier { 'stopVideoRecording was called when no video is recording.', ); } + + if (value.isStreamingImages) { + stopImageStream(); + } + try { final XFile file = await CameraPlatform.instance.stopVideoRecording(_cameraId); diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 0f75d10c36cd..963b75d1456c 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for controlling the camera. Supports previewing Dart. repository: https://github.com/flutter/plugins/tree/main/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.10.0+4 +version: 0.10.1 environment: sdk: ">=2.14.0 <3.0.0" @@ -21,10 +21,10 @@ flutter: default_package: camera_web dependencies: - camera_android: ^0.10.0 - camera_avfoundation: ^0.9.7+1 - camera_platform_interface: ^2.2.0 - camera_web: ^0.3.0 + camera_android: ^0.10.1 + camera_avfoundation: ^0.10.0 + camera_platform_interface: ^2.3.1 + camera_web: ^0.3.1 flutter: sdk: flutter flutter_plugin_android_lifecycle: ^2.0.2 diff --git a/packages/camera/camera/test/camera_image_stream_test.dart b/packages/camera/camera/test/camera_image_stream_test.dart index a9320e46dfb5..df855663d833 100644 --- a/packages/camera/camera/test/camera_image_stream_test.dart +++ b/packages/camera/camera/test/camera_image_stream_test.dart @@ -130,7 +130,7 @@ void main() { ); }); - test('stopImageStream() throws $CameraException when recording videos', + test('stopImageStream() throws $CameraException when not streaming images', () async { final CameraController cameraController = CameraController( const CameraDescription( @@ -140,20 +140,16 @@ void main() { ResolutionPreset.max); await cameraController.initialize(); - await cameraController.startImageStream((CameraImage image) => null); - cameraController.value = - cameraController.value.copyWith(isRecordingVideo: true); expect( cameraController.stopImageStream, throwsA(isA().having( (CameraException error) => error.description, - 'A video recording is already started.', - 'stopImageStream was called while a video is being recorded.', + 'No camera is streaming images', + 'stopImageStream was called when no camera is streaming images.', ))); }); - test('stopImageStream() throws $CameraException when not streaming images', - () async { + test('stopImageStream() intended behaviour', () async { final CameraController cameraController = CameraController( const CameraDescription( name: 'cam', @@ -161,29 +157,44 @@ void main() { sensorOrientation: 90), ResolutionPreset.max); await cameraController.initialize(); + await cameraController.startImageStream((CameraImage image) => null); + await cameraController.stopImageStream(); - expect( - cameraController.stopImageStream, - throwsA(isA().having( - (CameraException error) => error.description, - 'No camera is streaming images', - 'stopImageStream was called when no camera is streaming images.', - ))); + expect(mockPlatform.streamCallLog, + ['onStreamedFrameAvailable', 'listen', 'cancel']); }); - test('stopImageStream() intended behaviour', () async { + test('startVideoRecording() can stream images', () async { + final CameraController cameraController = CameraController( + const CameraDescription( + name: 'cam', + lensDirection: CameraLensDirection.back, + sensorOrientation: 90), + ResolutionPreset.max); + + await cameraController.initialize(); + + cameraController.startVideoRecording( + onAvailable: (CameraImage image) => null); + + expect(mockPlatform.streamCallLog, + ['startVideoRecording with stream']); + }); + + test('startVideoRecording() by default does not stream', () async { final CameraController cameraController = CameraController( const CameraDescription( name: 'cam', lensDirection: CameraLensDirection.back, sensorOrientation: 90), ResolutionPreset.max); + await cameraController.initialize(); - await cameraController.startImageStream((CameraImage image) => null); - await cameraController.stopImageStream(); + + cameraController.startVideoRecording(); expect(mockPlatform.streamCallLog, - ['onStreamedFrameAvailable', 'listen', 'cancel']); + ['startVideoRecording without stream']); }); } @@ -203,6 +214,20 @@ class MockStreamingCameraPlatform extends MockCameraPlatform { return _streamController!.stream; } + @override + Future startVideoRecording(int cameraId, + {Duration? maxVideoDuration}) { + streamCallLog.add('startVideoRecording'); + return super + .startVideoRecording(cameraId, maxVideoDuration: maxVideoDuration); + } + + @override + Future startVideoCapturing(VideoCaptureOptions options) { + streamCallLog.add('startVideoCapturing'); + return super.startVideoCapturing(options); + } + void _onFrameStreamListen() { streamCallLog.add('listen'); } diff --git a/packages/camera/camera/test/camera_preview_test.dart b/packages/camera/camera/test/camera_preview_test.dart index bedb0ea8e01f..38344759c11d 100644 --- a/packages/camera/camera/test/camera_preview_test.dart +++ b/packages/camera/camera/test/camera_preview_test.dart @@ -98,7 +98,8 @@ class FakeController extends ValueNotifier Future startImageStream(onLatestImageAvailable onAvailable) async {} @override - Future startVideoRecording() async {} + Future startVideoRecording( + {onLatestImageAvailable? onAvailable}) async {} @override Future stopImageStream() async {} diff --git a/packages/camera/camera/test/camera_test.dart b/packages/camera/camera/test/camera_test.dart index 3c12648f13b9..a6cc17f71dc0 100644 --- a/packages/camera/camera/test/camera_test.dart +++ b/packages/camera/camera/test/camera_test.dart @@ -336,30 +336,6 @@ void main() { ))); }); - test( - 'startVideoRecording() throws $CameraException when already streaming images', - () async { - final CameraController cameraController = CameraController( - const CameraDescription( - name: 'cam', - lensDirection: CameraLensDirection.back, - sensorOrientation: 90), - ResolutionPreset.max); - - await cameraController.initialize(); - - cameraController.value = - cameraController.value.copyWith(isStreamingImages: true); - - expect( - cameraController.startVideoRecording(), - throwsA(isA().having( - (CameraException error) => error.description, - 'A camera has started streaming images.', - 'startVideoRecording was called while a camera was streaming images.', - ))); - }); - test('getMaxZoomLevel() throws $CameraException when uninitialized', () async { final CameraController cameraController = CameraController( @@ -1459,6 +1435,12 @@ class MockCameraPlatform extends Mock {Duration? maxVideoDuration}) => Future.value(mockVideoRecordingXFile); + @override + Future startVideoCapturing(VideoCaptureOptions options) { + return startVideoRecording(options.cameraId, + maxVideoDuration: options.maxDuration); + } + @override Future lockCaptureOrientation( int? cameraId, DeviceOrientation? orientation) async => diff --git a/packages/camera/camera_android/CHANGELOG.md b/packages/camera/camera_android/CHANGELOG.md index a62d3169e409..eeb504726fc6 100644 --- a/packages/camera/camera_android/CHANGELOG.md +++ b/packages/camera/camera_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.10.1 + +* Implements option to also stream when recording a video. + ## 0.10.0+4 * Upgrades `androidx.annotation` version to 1.5.0. diff --git a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java index 3d2df98b60da..7c592b9c7e99 100644 --- a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java +++ b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java @@ -522,6 +522,21 @@ private void refreshPreviewCaptureSession( } } + private void startCapture(boolean record, boolean stream) throws CameraAccessException { + List surfaces = new ArrayList<>(); + Runnable successCallback = null; + if (record) { + surfaces.add(mediaRecorder.getSurface()); + successCallback = () -> mediaRecorder.start(); + } + if (stream) { + surfaces.add(imageStreamReader.getSurface()); + } + + createCaptureSession( + CameraDevice.TEMPLATE_RECORD, successCallback, surfaces.toArray(new Surface[0])); + } + public void takePicture(@NonNull final Result result) { // Only take one picture at a time. if (cameraCaptureCallback.getCameraState() != CameraState.STATE_PREVIEW) { @@ -731,29 +746,17 @@ private void unlockAutoFocus() { dartMessenger.error(flutterResult, errorCode, errorMessage, null)); } - public void startVideoRecording(@NonNull Result result) { - final File outputDir = applicationContext.getCacheDir(); - try { - captureFile = File.createTempFile("REC", ".mp4", outputDir); - } catch (IOException | SecurityException e) { - result.error("cannotCreateFile", e.getMessage(), null); - return; - } - try { - prepareMediaRecorder(captureFile.getAbsolutePath()); - } catch (IOException e) { - recordingVideo = false; - captureFile = null; - result.error("videoRecordingFailed", e.getMessage(), null); - return; + public void startVideoRecording( + @NonNull Result result, @Nullable EventChannel imageStreamChannel) { + prepareRecording(result); + + if (imageStreamChannel != null) { + setStreamHandler(imageStreamChannel); } - // Re-create autofocus feature so it's using video focus mode now. - cameraFeatures.setAutoFocus( - cameraFeatureFactory.createAutoFocusFeature(cameraProperties, true)); + recordingVideo = true; try { - createCaptureSession( - CameraDevice.TEMPLATE_RECORD, () -> mediaRecorder.start(), mediaRecorder.getSurface()); + startCapture(true, imageStreamChannel != null); result.success(null); } catch (CameraAccessException e) { recordingVideo = false; @@ -1073,21 +1076,10 @@ public void startPreview() throws CameraAccessException { public void startPreviewWithImageStream(EventChannel imageStreamChannel) throws CameraAccessException { - createCaptureSession(CameraDevice.TEMPLATE_RECORD, imageStreamReader.getSurface()); - Log.i(TAG, "startPreviewWithImageStream"); - - imageStreamChannel.setStreamHandler( - new EventChannel.StreamHandler() { - @Override - public void onListen(Object o, EventChannel.EventSink imageStreamSink) { - setImageStreamImageAvailableListener(imageStreamSink); - } + setStreamHandler(imageStreamChannel); - @Override - public void onCancel(Object o) { - imageStreamReader.setOnImageAvailableListener(null, backgroundHandler); - } - }); + startCapture(false, true); + Log.i(TAG, "startPreviewWithImageStream"); } /** @@ -1117,6 +1109,42 @@ public void onError(String errorCode, String errorMessage) { cameraCaptureCallback.setCameraState(CameraState.STATE_PREVIEW); } + private void prepareRecording(@NonNull Result result) { + final File outputDir = applicationContext.getCacheDir(); + try { + captureFile = File.createTempFile("REC", ".mp4", outputDir); + } catch (IOException | SecurityException e) { + result.error("cannotCreateFile", e.getMessage(), null); + return; + } + try { + prepareMediaRecorder(captureFile.getAbsolutePath()); + } catch (IOException e) { + recordingVideo = false; + captureFile = null; + result.error("videoRecordingFailed", e.getMessage(), null); + return; + } + // Re-create autofocus feature so it's using video focus mode now. + cameraFeatures.setAutoFocus( + cameraFeatureFactory.createAutoFocusFeature(cameraProperties, true)); + } + + private void setStreamHandler(EventChannel imageStreamChannel) { + imageStreamChannel.setStreamHandler( + new EventChannel.StreamHandler() { + @Override + public void onListen(Object o, EventChannel.EventSink imageStreamSink) { + setImageStreamImageAvailableListener(imageStreamSink); + } + + @Override + public void onCancel(Object o) { + imageStreamReader.setOnImageAvailableListener(null, backgroundHandler); + } + }); + } + private void setImageStreamImageAvailableListener(final EventChannel.EventSink imageStreamSink) { imageStreamReader.setOnImageAvailableListener( reader -> { diff --git a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java index 38201e1136c9..432344ade8cd 100644 --- a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java +++ b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java @@ -26,6 +26,7 @@ import io.flutter.view.TextureRegistry; import java.util.HashMap; import java.util.Map; +import java.util.Objects; final class MethodCallHandlerImpl implements MethodChannel.MethodCallHandler { private final Activity activity; @@ -118,7 +119,9 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull final Result result) } case "startVideoRecording": { - camera.startVideoRecording(result); + camera.startVideoRecording( + result, + Objects.equals(call.argument("enableStream"), true) ? imageStreamChannel : null); break; } case "stopVideoRecording": diff --git a/packages/camera/camera_android/example/integration_test/camera_test.dart b/packages/camera/camera_android/example/integration_test/camera_test.dart index 3b1aae6aec51..0d9b4ecd34af 100644 --- a/packages/camera/camera_android/example/integration_test/camera_test.dart +++ b/packages/camera/camera_android/example/integration_test/camera_test.dart @@ -245,4 +245,44 @@ void main() { await controller.dispose(); }, ); + + testWidgets( + 'recording with image stream', + (WidgetTester tester) async { + final List cameras = + await CameraPlatform.instance.availableCameras(); + if (cameras.isEmpty) { + return; + } + + final CameraController controller = CameraController( + cameras[0], + ResolutionPreset.low, + enableAudio: false, + ); + + await controller.initialize(); + bool _isDetecting = false; + + await controller.startVideoRecording( + streamCallback: (CameraImageData image) { + if (_isDetecting) { + return; + } + + _isDetecting = true; + + expectLater(image, isNotNull).whenComplete(() => _isDetecting = false); + }); + + expect(controller.value.isStreamingImages, true); + + sleep(const Duration(milliseconds: 500)); + + await controller.stopVideoRecording(); + await controller.dispose(); + + expect(controller.value.isStreamingImages, false); + }, + ); } diff --git a/packages/camera/camera_android/example/lib/camera_controller.dart b/packages/camera/camera_android/example/lib/camera_controller.dart index 09441cc5449c..94d8ef6540b1 100644 --- a/packages/camera/camera_android/example/lib/camera_controller.dart +++ b/packages/camera/camera_android/example/lib/camera_controller.dart @@ -306,11 +306,14 @@ class CameraController extends ValueNotifier { /// /// The video is returned as a [XFile] after calling [stopVideoRecording]. /// Throws a [CameraException] if the capture fails. - Future startVideoRecording() async { - await CameraPlatform.instance.startVideoRecording(_cameraId); + Future startVideoRecording( + {Function(CameraImageData image)? streamCallback}) async { + await CameraPlatform.instance.startVideoCapturing( + VideoCaptureOptions(_cameraId, streamCallback: streamCallback)); value = value.copyWith( isRecordingVideo: true, isRecordingPaused: false, + isStreamingImages: streamCallback != null, recordingOrientation: Optional.of( value.lockedCaptureOrientation ?? value.deviceOrientation)); } @@ -319,10 +322,15 @@ class CameraController extends ValueNotifier { /// /// Throws a [CameraException] if the capture failed. Future stopVideoRecording() async { + if (value.isStreamingImages) { + await stopImageStream(); + } + final XFile file = await CameraPlatform.instance.stopVideoRecording(_cameraId); value = value.copyWith( isRecordingVideo: false, + isRecordingPaused: false, recordingOrientation: const Optional.absent(), ); return file; diff --git a/packages/camera/camera_android/example/pubspec.yaml b/packages/camera/camera_android/example/pubspec.yaml index 2e530e02ca71..8c985d94fd5a 100644 --- a/packages/camera/camera_android/example/pubspec.yaml +++ b/packages/camera/camera_android/example/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../ - camera_platform_interface: ^2.2.0 + camera_platform_interface: ^2.3.1 flutter: sdk: flutter path_provider: ^2.0.0 diff --git a/packages/camera/camera_android/lib/src/android_camera.dart b/packages/camera/camera_android/lib/src/android_camera.dart index 36077eac8eed..4b342eee08d5 100644 --- a/packages/camera/camera_android/lib/src/android_camera.dart +++ b/packages/camera/camera_android/lib/src/android_camera.dart @@ -248,13 +248,25 @@ class AndroidCamera extends CameraPlatform { @override Future startVideoRecording(int cameraId, {Duration? maxVideoDuration}) async { + return startVideoCapturing( + VideoCaptureOptions(cameraId, maxDuration: maxVideoDuration)); + } + + @override + Future startVideoCapturing(VideoCaptureOptions options) async { await _channel.invokeMethod( 'startVideoRecording', { - 'cameraId': cameraId, - 'maxVideoDuration': maxVideoDuration?.inMilliseconds, + 'cameraId': options.cameraId, + 'maxVideoDuration': options.maxDuration?.inMilliseconds, + 'enableStream': options.streamCallback != null, }, ); + + if (options.streamCallback != null) { + _installStreamController().stream.listen(options.streamCallback); + _startStreamListener(); + } } @override @@ -290,13 +302,19 @@ class AndroidCamera extends CameraPlatform { @override Stream onStreamedFrameAvailable(int cameraId, {CameraImageStreamOptions? options}) { + _installStreamController(onListen: _onFrameStreamListen); + return _frameStreamController!.stream; + } + + StreamController _installStreamController( + {Function()? onListen}) { _frameStreamController = StreamController( - onListen: _onFrameStreamListen, + onListen: onListen ?? () {}, onPause: _onFrameStreamPauseResume, onResume: _onFrameStreamPauseResume, onCancel: _onFrameStreamCancel, ); - return _frameStreamController!.stream; + return _frameStreamController!; } void _onFrameStreamListen() { @@ -305,6 +323,10 @@ class AndroidCamera extends CameraPlatform { Future _startPlatformStream() async { await _channel.invokeMethod('startImageStream'); + _startStreamListener(); + } + + void _startStreamListener() { const EventChannel cameraEventChannel = EventChannel('plugins.flutter.io/camera_android/imageStream'); _platformImageStreamSubscription = diff --git a/packages/camera/camera_android/pubspec.yaml b/packages/camera/camera_android/pubspec.yaml index 6f1b667670e8..7ed5077c315e 100644 --- a/packages/camera/camera_android/pubspec.yaml +++ b/packages/camera/camera_android/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_android description: Android implementation of the camera plugin. repository: https://github.com/flutter/plugins/tree/main/packages/camera/camera_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.10.0+4 +version: 0.10.1 environment: sdk: ">=2.14.0 <3.0.0" @@ -18,7 +18,7 @@ flutter: dartPluginClass: AndroidCamera dependencies: - camera_platform_interface: ^2.2.0 + camera_platform_interface: ^2.3.1 flutter: sdk: flutter flutter_plugin_android_lifecycle: ^2.0.2 diff --git a/packages/camera/camera_android/test/android_camera_test.dart b/packages/camera/camera_android/test/android_camera_test.dart index 3e50e6918648..14073cb076d9 100644 --- a/packages/camera/camera_android/test/android_camera_test.dart +++ b/packages/camera/camera_android/test/android_camera_test.dart @@ -587,6 +587,7 @@ void main() { isMethodCall('startVideoRecording', arguments: { 'cameraId': cameraId, 'maxVideoDuration': null, + 'enableStream': false, }), ]); }); @@ -609,7 +610,33 @@ void main() { expect(channel.log, [ isMethodCall('startVideoRecording', arguments: { 'cameraId': cameraId, - 'maxVideoDuration': 10000 + 'maxVideoDuration': 10000, + 'enableStream': false, + }), + ]); + }); + + test( + 'Should pass enableStream if callback is passed when starting recording a video', + () async { + // Arrange + final MethodChannelMock channel = MethodChannelMock( + channelName: _channelName, + methods: {'startVideoRecording': null}, + ); + + // Act + await camera.startVideoRecording( + cameraId, + streamCallback: (imageData) {}, + ); + + // Assert + expect(channel.log, [ + isMethodCall('startVideoRecording', arguments: { + 'cameraId': cameraId, + 'maxVideoDuration': null, + 'enableStream': true, }), ]); }); diff --git a/packages/camera/camera_avfoundation/CHANGELOG.md b/packages/camera/camera_avfoundation/CHANGELOG.md index 12d9a53ea248..7c6cfe9c9f09 100644 --- a/packages/camera/camera_avfoundation/CHANGELOG.md +++ b/packages/camera/camera_avfoundation/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.10.0 + +* Implements option to also stream when recording a video. + ## 0.9.8+6 * Updates code for `no_leading_underscores_for_local_identifiers` lint. diff --git a/packages/camera/camera_avfoundation/example/integration_test/camera_test.dart b/packages/camera/camera_avfoundation/example/integration_test/camera_test.dart index 3e62edc2c495..cef26808437d 100644 --- a/packages/camera/camera_avfoundation/example/integration_test/camera_test.dart +++ b/packages/camera/camera_avfoundation/example/integration_test/camera_test.dart @@ -253,4 +253,33 @@ void main() { expect(image.planes.length, 1); }, ); + + testWidgets('Recording with video streaming', (WidgetTester tester) async { + final List cameras = + await CameraPlatform.instance.availableCameras(); + if (cameras.isEmpty) { + return; + } + + final CameraController controller = CameraController( + cameras[0], + ResolutionPreset.low, + enableAudio: false, + ); + + await controller.initialize(); + await controller.prepareForVideoRecording(); + final Completer _completer = Completer(); + await controller.startVideoRecording( + streamCallback: (CameraImageData image) { + if (!_completer.isCompleted) { + _completer.complete(image); + } + }); + sleep(const Duration(milliseconds: 500)); + await controller.stopVideoRecording(); + await controller.dispose(); + + expect(await _completer.future, isNotNull); + }); } diff --git a/packages/camera/camera_avfoundation/example/lib/camera_controller.dart b/packages/camera/camera_avfoundation/example/lib/camera_controller.dart index 09441cc5449c..2fcd868934fb 100644 --- a/packages/camera/camera_avfoundation/example/lib/camera_controller.dart +++ b/packages/camera/camera_avfoundation/example/lib/camera_controller.dart @@ -306,11 +306,14 @@ class CameraController extends ValueNotifier { /// /// The video is returned as a [XFile] after calling [stopVideoRecording]. /// Throws a [CameraException] if the capture fails. - Future startVideoRecording() async { - await CameraPlatform.instance.startVideoRecording(_cameraId); + Future startVideoRecording( + {Function(CameraImageData image)? streamCallback}) async { + await CameraPlatform.instance.startVideoCapturing( + VideoCaptureOptions(_cameraId, streamCallback: streamCallback)); value = value.copyWith( isRecordingVideo: true, isRecordingPaused: false, + isStreamingImages: streamCallback != null, recordingOrientation: Optional.of( value.lockedCaptureOrientation ?? value.deviceOrientation)); } @@ -319,6 +322,10 @@ class CameraController extends ValueNotifier { /// /// Throws a [CameraException] if the capture failed. Future stopVideoRecording() async { + if (value.isStreamingImages) { + await stopImageStream(); + } + final XFile file = await CameraPlatform.instance.stopVideoRecording(_cameraId); value = value.copyWith( diff --git a/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m b/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m index 628211ac7f7a..b85f68d1f957 100644 --- a/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m @@ -201,7 +201,12 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call [self.camera setUpCaptureSessionForAudio]; [result sendSuccess]; } else if ([@"startVideoRecording" isEqualToString:call.method]) { - [_camera startVideoRecordingWithResult:result]; + BOOL enableStream = [call.arguments[@"enableStream"] boolValue]; + if (enableStream) { + [_camera startVideoRecordingWithResult:result messengerForStreaming:_messenger]; + } else { + [_camera startVideoRecordingWithResult:result]; + } } else if ([@"stopVideoRecording" isEqualToString:call.method]) { [_camera stopVideoRecordingWithResult:result]; } else if ([@"pauseVideoRecording" isEqualToString:call.method]) { diff --git a/packages/camera/camera_avfoundation/ios/Classes/FLTCam.h b/packages/camera/camera_avfoundation/ios/Classes/FLTCam.h index 8a5dafaf8354..bea4efde67f5 100644 --- a/packages/camera/camera_avfoundation/ios/Classes/FLTCam.h +++ b/packages/camera/camera_avfoundation/ios/Classes/FLTCam.h @@ -50,6 +50,15 @@ NS_ASSUME_NONNULL_BEGIN - (void)captureToFile:(FLTThreadSafeFlutterResult *)result API_AVAILABLE(ios(10)); - (void)close; - (void)startVideoRecordingWithResult:(FLTThreadSafeFlutterResult *)result; +/** + * Starts recording a video with an optional streaming messenger. + * If the messenger is non-null then it will be called for each + * captured frame, allowing streaming concurrently with recording. + * + * @param messengerForStreaming Nullable messenger for capturing each frame. + */ +- (void)startVideoRecordingWithResult:(FLTThreadSafeFlutterResult *)result + messengerForStreaming:(nullable NSObject *)messenger; - (void)stopVideoRecordingWithResult:(FLTThreadSafeFlutterResult *)result; - (void)pauseVideoRecordingWithResult:(FLTThreadSafeFlutterResult *)result; - (void)resumeVideoRecordingWithResult:(FLTThreadSafeFlutterResult *)result; diff --git a/packages/camera/camera_avfoundation/ios/Classes/FLTCam.m b/packages/camera/camera_avfoundation/ios/Classes/FLTCam.m index 90b81adbd84c..a7d6cd24be3c 100644 --- a/packages/camera/camera_avfoundation/ios/Classes/FLTCam.m +++ b/packages/camera/camera_avfoundation/ios/Classes/FLTCam.m @@ -623,7 +623,16 @@ - (CVPixelBufferRef)copyPixelBuffer { } - (void)startVideoRecordingWithResult:(FLTThreadSafeFlutterResult *)result { + [self startVideoRecordingWithResult:result messengerForStreaming:nil]; +} + +- (void)startVideoRecordingWithResult:(FLTThreadSafeFlutterResult *)result + messengerForStreaming:(nullable NSObject *)messenger { if (!_isRecording) { + if (messenger != nil) { + [self startImageStreamWithMessenger:messenger]; + } + NSError *error; _videoRecordingPath = [self getTemporaryFilePathWithExtension:@"mp4" subfolder:@"videos" diff --git a/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart b/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart index 9bdadfb4536f..011616d2d9f4 100644 --- a/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart +++ b/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart @@ -248,13 +248,26 @@ class AVFoundationCamera extends CameraPlatform { @override Future startVideoRecording(int cameraId, {Duration? maxVideoDuration}) async { + return startVideoCapturing( + VideoCaptureOptions(cameraId, maxDuration: maxVideoDuration)); + } + + @override + Future startVideoCapturing(VideoCaptureOptions options) async { await _channel.invokeMethod( 'startVideoRecording', { - 'cameraId': cameraId, - 'maxVideoDuration': maxVideoDuration?.inMilliseconds, + 'cameraId': options.cameraId, + 'maxVideoDuration': options.maxDuration?.inMilliseconds, + 'enableStream': options.streamCallback != null, }, ); + + if (options.streamCallback != null) { + _frameStreamController = _createStreamController(); + _frameStreamController!.stream.listen(options.streamCallback); + _startStreamListener(); + } } @override @@ -290,13 +303,19 @@ class AVFoundationCamera extends CameraPlatform { @override Stream onStreamedFrameAvailable(int cameraId, {CameraImageStreamOptions? options}) { - _frameStreamController = StreamController( - onListen: _onFrameStreamListen, + _frameStreamController = + _createStreamController(onListen: _onFrameStreamListen); + return _frameStreamController!.stream; + } + + StreamController _createStreamController( + {Function()? onListen}) { + return StreamController( + onListen: onListen ?? () {}, onPause: _onFrameStreamPauseResume, onResume: _onFrameStreamPauseResume, onCancel: _onFrameStreamCancel, ); - return _frameStreamController!.stream; } void _onFrameStreamListen() { @@ -305,6 +324,10 @@ class AVFoundationCamera extends CameraPlatform { Future _startPlatformStream() async { await _channel.invokeMethod('startImageStream'); + _startStreamListener(); + } + + void _startStreamListener() { const EventChannel cameraEventChannel = EventChannel('plugins.flutter.io/camera_avfoundation/imageStream'); _platformImageStreamSubscription = diff --git a/packages/camera/camera_avfoundation/pubspec.yaml b/packages/camera/camera_avfoundation/pubspec.yaml index f394d59e81d5..9027ad1a295b 100644 --- a/packages/camera/camera_avfoundation/pubspec.yaml +++ b/packages/camera/camera_avfoundation/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_avfoundation description: iOS implementation of the camera plugin. repository: https://github.com/flutter/plugins/tree/main/packages/camera/camera_avfoundation issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.9.8+6 +version: 0.10.0 environment: sdk: ">=2.14.0 <3.0.0" @@ -17,7 +17,7 @@ flutter: dartPluginClass: AVFoundationCamera dependencies: - camera_platform_interface: ^2.2.0 + camera_platform_interface: ^2.3.1 flutter: sdk: flutter stream_transform: ^2.0.0 diff --git a/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart b/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart index 60109a4172b7..f5c9f9651a61 100644 --- a/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart +++ b/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart @@ -587,6 +587,7 @@ void main() { isMethodCall('startVideoRecording', arguments: { 'cameraId': cameraId, 'maxVideoDuration': null, + 'enableStream': false, }), ]); }); @@ -609,7 +610,33 @@ void main() { expect(channel.log, [ isMethodCall('startVideoRecording', arguments: { 'cameraId': cameraId, - 'maxVideoDuration': 10000 + 'maxVideoDuration': 10000, + 'enableStream': false, + }), + ]); + }); + + test( + 'Should pass enableStream if callback is passed when starting recording a video', + () async { + // Arrange + final MethodChannelMock channel = MethodChannelMock( + channelName: _channelName, + methods: {'startVideoRecording': null}, + ); + + // Act + await camera.startVideoRecording( + cameraId, + streamCallback: (imageData) {}, + ); + + // Assert + expect(channel.log, [ + isMethodCall('startVideoRecording', arguments: { + 'cameraId': cameraId, + 'maxVideoDuration': null, + 'enableStream': true, }), ]); }); diff --git a/packages/camera/camera_platform_interface/CHANGELOG.md b/packages/camera/camera_platform_interface/CHANGELOG.md index 3bfc56f3f6e2..b35cb1727e67 100644 --- a/packages/camera/camera_platform_interface/CHANGELOG.md +++ b/packages/camera/camera_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.3.1 + +* Exports VideoCaptureOptions to allow dependencies to implement concurrent stream and record + ## 2.3.0 * Adds new capture method for a camera to allow concurrent streaming and recording. diff --git a/packages/camera/camera_platform_interface/lib/src/method_channel/method_channel_camera.dart b/packages/camera/camera_platform_interface/lib/src/method_channel/method_channel_camera.dart index 37c00d64ede2..34c3fa2cca36 100644 --- a/packages/camera/camera_platform_interface/lib/src/method_channel/method_channel_camera.dart +++ b/packages/camera/camera_platform_interface/lib/src/method_channel/method_channel_camera.dart @@ -240,13 +240,25 @@ class MethodChannelCamera extends CameraPlatform { @override Future startVideoRecording(int cameraId, {Duration? maxVideoDuration}) async { + return startVideoCapturing( + VideoCaptureOptions(cameraId, maxDuration: maxVideoDuration)); + } + + @override + Future startVideoCapturing(VideoCaptureOptions options) async { await _channel.invokeMethod( 'startVideoRecording', { - 'cameraId': cameraId, - 'maxVideoDuration': maxVideoDuration?.inMilliseconds, + 'cameraId': options.cameraId, + 'maxVideoDuration': options.maxDuration?.inMilliseconds, + 'enableStream': options.streamCallback != null, }, ); + + if (options.streamCallback != null) { + _installStreamController().stream.listen(options.streamCallback); + _startStreamListener(); + } } @override @@ -282,13 +294,19 @@ class MethodChannelCamera extends CameraPlatform { @override Stream onStreamedFrameAvailable(int cameraId, {CameraImageStreamOptions? options}) { + _installStreamController(onListen: _onFrameStreamListen); + return _frameStreamController!.stream; + } + + StreamController _installStreamController( + {Function()? onListen}) { _frameStreamController = StreamController( - onListen: _onFrameStreamListen, + onListen: onListen ?? () {}, onPause: _onFrameStreamPauseResume, onResume: _onFrameStreamPauseResume, onCancel: _onFrameStreamCancel, ); - return _frameStreamController!.stream; + return _frameStreamController!; } void _onFrameStreamListen() { @@ -297,6 +315,10 @@ class MethodChannelCamera extends CameraPlatform { Future _startPlatformStream() async { await _channel.invokeMethod('startImageStream'); + _startStreamListener(); + } + + void _startStreamListener() { const EventChannel cameraEventChannel = EventChannel('plugins.flutter.io/camera/imageStream'); _platformImageStreamSubscription = diff --git a/packages/camera/camera_platform_interface/lib/src/types/types.dart b/packages/camera/camera_platform_interface/lib/src/types/types.dart index 3eb09fcb833c..a8a4f8ca5dc4 100644 --- a/packages/camera/camera_platform_interface/lib/src/types/types.dart +++ b/packages/camera/camera_platform_interface/lib/src/types/types.dart @@ -10,3 +10,4 @@ export 'flash_mode.dart'; export 'focus_mode.dart'; export 'image_format_group.dart'; export 'resolution_preset.dart'; +export 'video_capture_options.dart'; diff --git a/packages/camera/camera_platform_interface/pubspec.yaml b/packages/camera/camera_platform_interface/pubspec.yaml index 7ddc6d561aa4..19642a0c958e 100644 --- a/packages/camera/camera_platform_interface/pubspec.yaml +++ b/packages/camera/camera_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/plugins/tree/main/packages/camera/camera_ issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.3.0 +version: 2.3.1 environment: sdk: '>=2.12.0 <3.0.0' diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md index f4989cfd5bff..95c90924f5b2 100644 --- a/packages/camera/camera_web/CHANGELOG.md +++ b/packages/camera/camera_web/CHANGELOG.md @@ -5,6 +5,10 @@ * Fixes avoid_redundant_argument_values lint warnings and minor typos. * Ignores unnecessary import warnings in preparation for [upcoming Flutter changes](https://github.com/flutter/flutter/pull/106316). +## 0.3.1 + +* Update to latest camera platform interface, and fail if user attempts to use streaming with recording (since streaming is currently unsupported on web). + ## 0.3.0 * **BREAKING CHANGE**: Renames error code `cameraPermission` to `CameraAccessDenied` to be consistent with other platforms. diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index d440653cd424..52fdc1c3f8d6 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -451,23 +451,33 @@ class CameraPlugin extends CameraPlatform { @override Future startVideoRecording(int cameraId, {Duration? maxVideoDuration}) { + return startVideoCapturing( + VideoCaptureOptions(cameraId, maxDuration: maxVideoDuration)); + } + + @override + Future startVideoCapturing(VideoCaptureOptions options) { + if (options.streamCallback != null || options.streamOptions != null) { + throw UnimplementedError('Streaming is not currently supported on web'); + } + try { - final Camera camera = getCamera(cameraId); + final Camera camera = getCamera(options.cameraId); // Add camera's video recording errors to the camera events stream. // The error event fires when the video recording is not allowed or an unsupported // codec is used. - _cameraVideoRecordingErrorSubscriptions[cameraId] = + _cameraVideoRecordingErrorSubscriptions[options.cameraId] = camera.onVideoRecordingError.listen((html.ErrorEvent errorEvent) { cameraEventStreamController.add( CameraErrorEvent( - cameraId, + options.cameraId, 'Error code: ${errorEvent.type}, error message: ${errorEvent.message}.', ), ); }); - return camera.startVideoRecording(maxVideoDuration: maxVideoDuration); + return camera.startVideoRecording(maxVideoDuration: options.maxDuration); } on html.DomException catch (e) { throw PlatformException(code: e.name, message: e.message); } on CameraWebException catch (e) { diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index ef9c45c71796..4661d9ac00d9 100644 --- a/packages/camera/camera_web/pubspec.yaml +++ b/packages/camera/camera_web/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_web description: A Flutter plugin for getting information about and controlling the camera on Web. repository: https://github.com/flutter/plugins/tree/main/packages/camera/camera_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.3.0+1 +version: 0.3.0 environment: sdk: ">=2.12.0 <3.0.0" @@ -17,7 +17,7 @@ flutter: fileName: camera_web.dart dependencies: - camera_platform_interface: ^2.1.0 + camera_platform_interface: ^2.3.1 flutter: sdk: flutter flutter_web_plugins: diff --git a/packages/camera/camera_windows/CHANGELOG.md b/packages/camera/camera_windows/CHANGELOG.md index 71c5d56524a6..16a2906be4d5 100644 --- a/packages/camera/camera_windows/CHANGELOG.md +++ b/packages/camera/camera_windows/CHANGELOG.md @@ -1,3 +1,7 @@ +## NEXT + +* Updates to latest camera platform interface but fails if user attempts to use streaming with recording (since streaming is currently unsupported on Windows). + ## 0.2.1+2 * Updates code for `no_leading_underscores_for_local_identifiers` lint. diff --git a/packages/camera/camera_windows/lib/camera_windows.dart b/packages/camera/camera_windows/lib/camera_windows.dart index 14134479994b..79dd305e2e14 100644 --- a/packages/camera/camera_windows/lib/camera_windows.dart +++ b/packages/camera/camera_windows/lib/camera_windows.dart @@ -214,15 +214,24 @@ class CameraWindows extends CameraPlatform { pluginChannel.invokeMethod('prepareForVideoRecording'); @override - Future startVideoRecording( - int cameraId, { - Duration? maxVideoDuration, - }) async { + Future startVideoRecording(int cameraId, + {Duration? maxVideoDuration}) async { + return startVideoCapturing( + VideoCaptureOptions(cameraId, maxDuration: maxVideoDuration)); + } + + @override + Future startVideoCapturing(VideoCaptureOptions options) async { + if (options.streamCallback != null || options.streamOptions != null) { + throw UnimplementedError( + 'Streaming is not currently supported on Windows'); + } + await pluginChannel.invokeMethod( 'startVideoRecording', { - 'cameraId': cameraId, - 'maxVideoDuration': maxVideoDuration?.inMilliseconds, + 'cameraId': options.cameraId, + 'maxVideoDuration': options.maxDuration?.inMilliseconds, }, ); } diff --git a/packages/camera/camera_windows/pubspec.yaml b/packages/camera/camera_windows/pubspec.yaml index 1eab9fa108ef..b8c0ed6ccad1 100644 --- a/packages/camera/camera_windows/pubspec.yaml +++ b/packages/camera/camera_windows/pubspec.yaml @@ -17,7 +17,7 @@ flutter: dartPluginClass: CameraWindows dependencies: - camera_platform_interface: ^2.1.2 + camera_platform_interface: ^2.3.1 cross_file: ^0.3.1 flutter: sdk: flutter