diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 1a2b03d93a6a..40a2715f32fe 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.7 + +* Added maxVideoDuration to startVideoRecording to limit the length of a recording. + ## 0.6.6 * Adds auto focus support for Android and iOS implementations. diff --git a/packages/camera/camera/README.md b/packages/camera/camera/README.md index f7163818aae3..5186fe1c4aeb 100644 --- a/packages/camera/camera/README.md +++ b/packages/camera/camera/README.md @@ -47,7 +47,7 @@ It's important to note that the `MediaRecorder` class is not working properly on ### Handling Lifecycle states -As of version [0.5.0](https://github.com/flutter/plugins/blob/master/packages/camera/CHANGELOG.md#050) of the camera plugin, lifecycle changes are no longer handled by the plugin. This means developers are now responsible to control camera resources when the lifecycle state is updated. Failure to do so might lead to unexpected behavior (for example as described in issue [#39109](https://github.com/flutter/flutter/issues/39109)). Handling lifecycle changes can be done by overriding the `didChangeAppLifecycleState` method like so: +As of version [0.5.0](https://github.com/flutter/plugins/blob/master/packages/camera/camera/CHANGELOG.md#050) of the camera plugin, lifecycle changes are no longer handled by the plugin. This means developers are now responsible to control camera resources when the lifecycle state is updated. Failure to do so might lead to unexpected behavior (for example as described in issue [#39109](https://github.com/flutter/flutter/issues/39109)). Handling lifecycle changes can be done by overriding the `didChangeAppLifecycleState` method like so: ```dart @override @@ -66,6 +66,33 @@ As of version [0.5.0](https://github.com/flutter/plugins/blob/master/packages/ca } ``` +As of version [0.6.5](https://github.com/flutter/plugins/blob/master/packages/camera/camera/CHANGELOG.md#065) the startVideoRecording method can be used with the maxVideoDuration. To do this the result of the recording needs to be retrieved by calling controller.onCameraTimeLimitReachedEvent which accepts a callback to retrieve the XFile result. Like so: + +```dart + Future startVideoRecording() async { + if (!controller.value.isInitialized) { + showInSnackBar('Error: select a camera first.'); + return; + } + + if (controller.value.isRecordingVideo) { + // A recording is already started, do nothing. + return; + } + + try { + await controller.startVideoRecording( + maxVideoDuration: const Duration(milliseconds: 5000), + ); + controller.onCameraTimeLimitReachedEvent(onCameraTimeLimitReached: (XFile file) { + //Handle the XFile + }); + } on CameraException catch (e) { + _showCameraException(e); + return; + } + } +``` ### Example Here is a small example flutter app displaying a full screen camera preview. @@ -122,7 +149,7 @@ class _CameraAppState extends State { } ``` -For a more elaborate usage example see [here](https://github.com/flutter/plugins/tree/master/packages/camera/example). +For a more elaborate usage example see [here](https://github.com/flutter/plugins/tree/master/packages/camera/camera/example). *Note*: This plugin is still under development, and some APIs might not be available yet. [Feedback welcome](https://github.com/flutter/flutter/issues) and diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java index 3fc702a2a879..46e568a88347 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java @@ -4,6 +4,7 @@ package io.flutter.plugins.camera; +import static android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED; import static android.view.OrientationEventListener.ORIENTATION_UNKNOWN; import static io.flutter.plugins.camera.CameraUtils.computeBestPreviewSize; @@ -106,6 +107,7 @@ public class Camera { private int exposureOffset; private boolean useAutoFocus = true; private Range fpsRange; + private Integer maxDurationLimit; private static final HashMap supportedImageFormats; // Current supported outputs @@ -187,16 +189,21 @@ private void initFps(CameraCharacteristics cameraCharacteristics) { Log.i("Camera", "[FPS Range] is:" + fpsRange); } - private void prepareMediaRecorder(String outputFilePath) throws IOException { + private void prepareMediaRecorder(String outputFilePath, Integer maxVideoDuration) + throws IOException { if (mediaRecorder != null) { mediaRecorder.release(); } - mediaRecorder = + MediaRecorderBuilder mediaRecorderBuilder = new MediaRecorderBuilder(recordingProfile, outputFilePath) .setEnableAudio(enableAudio) - .setMediaOrientation(getMediaOrientation()) - .build(); + .setMediaOrientation(getMediaOrientation()); + + if (maxVideoDuration != null) { + mediaRecorderBuilder.setMaxVideoDuration(maxVideoDuration); + } + mediaRecorder = mediaRecorderBuilder.build(); } @SuppressLint("MissingPermission") @@ -603,8 +610,9 @@ private void unlockAutoFocus() { (errorCode, errorMessage) -> pictureCaptureRequest.error(errorCode, errorMessage, null)); } - public void startVideoRecording(Result result) { + public void startVideoRecording(Result result, Integer maxVideoDuration) { final File outputDir = applicationContext.getCacheDir(); + maxDurationLimit = maxVideoDuration; try { videoRecordingFile = File.createTempFile("REC", ".mp4", outputDir); } catch (IOException | SecurityException e) { @@ -613,10 +621,27 @@ public void startVideoRecording(Result result) { } try { - prepareMediaRecorder(videoRecordingFile.getAbsolutePath()); + prepareMediaRecorder(videoRecordingFile.getAbsolutePath(), maxVideoDuration); recordingVideo = true; createCaptureSession( CameraDevice.TEMPLATE_RECORD, () -> mediaRecorder.start(), mediaRecorder.getSurface()); + if (maxVideoDuration != null) { + mediaRecorder.setOnInfoListener( + (mr, what, extra) -> { + if (what == MEDIA_RECORDER_INFO_MAX_DURATION_REACHED) { + try { + dartMessenger.sendVideoRecordedEvent( + videoRecordingFile.getAbsolutePath(), maxVideoDuration); + recordingVideo = false; + videoRecordingFile = null; + maxDurationLimit = null; + resetCaptureSession(); + } catch (CameraAccessException e) { + result.error("videoRecordingFailed", e.getMessage(), null); + } + } + }); + } result.success(null); } catch (CameraAccessException | IOException e) { recordingVideo = false; @@ -625,6 +650,18 @@ public void startVideoRecording(Result result) { } } + public void resetCaptureSession() throws CameraAccessException { + try { + cameraCaptureSession.abortCaptures(); + mediaRecorder.stop(); + } catch (IllegalStateException e) { + // Ignore exceptions and try to continue (chances are camera session already aborted capture) + } + + mediaRecorder.reset(); + startPreview(); + } + public void stopVideoRecording(@NonNull final Result result) { if (!recordingVideo) { result.success(null); @@ -634,18 +671,11 @@ public void stopVideoRecording(@NonNull final Result result) { try { recordingVideo = false; - try { - cameraCaptureSession.abortCaptures(); - mediaRecorder.stop(); - } catch (CameraAccessException | IllegalStateException e) { - // Ignore exceptions and try to continue (changes are camera session already aborted capture) - } - - mediaRecorder.reset(); - startPreview(); - result.success(videoRecordingFile.getAbsolutePath()); + resetCaptureSession(); + dartMessenger.sendVideoRecordedEvent(videoRecordingFile.getAbsolutePath(), maxDurationLimit); + maxDurationLimit = null; videoRecordingFile = null; - } catch (CameraAccessException | IllegalStateException e) { + } catch (CameraAccessException e) { result.error("videoRecordingFailed", e.getMessage(), null); } } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java index ec68ac0acda3..436ebe93e07d 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/DartMessenger.java @@ -20,6 +20,7 @@ enum EventType { ERROR, CAMERA_CLOSING, INITIALIZED, + VIDEO_RECORDED, } DartMessenger(BinaryMessenger messenger, long cameraId) { @@ -53,6 +54,17 @@ void sendCameraInitializedEvent( }); } + void sendVideoRecordedEvent(String path, Integer maxVideoDuration) { + this.send( + EventType.VIDEO_RECORDED, + new HashMap() { + { + if (path != null) put("path", path); + if (maxVideoDuration != null) put("maxVideoDuration", maxVideoDuration); + } + }); + } + void sendCameraClosingEvent() { send(EventType.CAMERA_CLOSING); } diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java index 36048dbb5176..462852b2e1d4 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java @@ -111,7 +111,7 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull final Result result) } case "startVideoRecording": { - camera.startVideoRecording(result); + camera.startVideoRecording(result, call.argument("maxVideoDuration")); break; } case "stopVideoRecording": diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/media/MediaRecorderBuilder.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/media/MediaRecorderBuilder.java index 4c3fb3add230..50214d10db32 100644 --- a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/media/MediaRecorderBuilder.java +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/media/MediaRecorderBuilder.java @@ -22,6 +22,7 @@ MediaRecorder makeMediaRecorder() { private boolean enableAudio; private int mediaOrientation; + private int maxVideoDuration; public MediaRecorderBuilder( @NonNull CamcorderProfile recordingProfile, @NonNull String outputFilePath) { @@ -47,6 +48,11 @@ public MediaRecorderBuilder setMediaOrientation(int orientation) { return this; } + public MediaRecorderBuilder setMaxVideoDuration(int maxVideoDuration) { + this.maxVideoDuration = maxVideoDuration; + return this; + } + public MediaRecorder build() throws IOException { MediaRecorder mediaRecorder = recorderFactory.makeMediaRecorder(); @@ -67,6 +73,8 @@ public MediaRecorder build() throws IOException { mediaRecorder.setOutputFile(outputFilePath); mediaRecorder.setOrientationHint(this.mediaOrientation); + mediaRecorder.setMaxDuration(maxVideoDuration); + mediaRecorder.prepare(); return mediaRecorder; diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart index 681a45172816..29152078dbdf 100644 --- a/packages/camera/camera/example/lib/main.dart +++ b/packages/camera/camera/example/lib/main.dart @@ -571,6 +571,10 @@ class _CameraExampleHomeState extends State try { await controller.initialize(); + controller.onVideoRecordedEvent().listen((VideoRecordedEvent event) { + // Handle VideoRecordedEvent + debugPrint('event is stream ${event.file.path}'); + }); _minAvailableExposureOffset = await controller.getMinExposureOffset(); _maxAvailableExposureOffset = await controller.getMaxExposureOffset(); _maxAvailableZoom = await controller.getMaxZoomLevel(); @@ -663,6 +667,7 @@ class _CameraExampleHomeState extends State void onStopButtonPressed() { stopVideoRecording().then((file) { + debugPrint('file after future ${file.path}'); if (mounted) setState(() {}); if (file != null) { showInSnackBar('Video recorded to ${file.path}'); @@ -698,7 +703,7 @@ class _CameraExampleHomeState extends State } try { - await controller.startVideoRecording(); + await controller.startVideoRecording(maxVideoDuration: null); } on CameraException catch (e) { _showCameraException(e); return; diff --git a/packages/camera/camera/ios/Classes/CameraPlugin.m b/packages/camera/camera/ios/Classes/CameraPlugin.m index 298b906ace7b..7899fdd215c1 100644 --- a/packages/camera/camera/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera/ios/Classes/CameraPlugin.m @@ -751,7 +751,8 @@ - (CVPixelBufferRef)copyPixelBuffer { return pixelBuffer; } -- (void)startVideoRecordingWithResult:(FlutterResult)result { +- (void)startVideoRecordingWithResult:(FlutterResult)result + maxVideoDuration:(int64_t)maxVideoDuration { if (!_isRecording) { NSError *error; _videoRecordingPath = [self getTemporaryFilePathWithExtension:@"mp4" @@ -766,6 +767,14 @@ - (void)startVideoRecordingWithResult:(FlutterResult)result { result([FlutterError errorWithCode:@"IOError" message:@"Setup Writer Failed" details:nil]); return; } + if (maxVideoDuration != 0) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(maxVideoDuration * NSEC_PER_MSEC)), + dispatch_get_main_queue(), ^{ + if (self->_isRecording) { + [self stopVideoRecording:maxVideoDuration]; + } + }); + } _isRecording = YES; _isRecordingPaused = NO; _videoTimeOffset = CMTimeMake(0, 1); @@ -778,27 +787,27 @@ - (void)startVideoRecordingWithResult:(FlutterResult)result { } } -- (void)stopVideoRecordingWithResult:(FlutterResult)result { +- (void)stopVideoRecording:(int64_t)maxVideoDuration { if (_isRecording) { _isRecording = NO; if (_videoWriter.status != AVAssetWriterStatusUnknown) { [_videoWriter finishWritingWithCompletionHandler:^{ if (self->_videoWriter.status == AVAssetWriterStatusCompleted) { - result(self->_videoRecordingPath); + [self->_methodChannel invokeMethod:@"video_recorded" + arguments:@{ + @"path" : self->_videoRecordingPath, + @"maxVideoDuration" : @(maxVideoDuration), + }]; + self->_videoRecordingPath = nil; } else { - result([FlutterError errorWithCode:@"IOError" - message:@"AVAssetWriter could not finish writing!" - details:nil]); + [self->_methodChannel invokeMethod:errorMethod + arguments:@"AVAssetWriter could not finish writing!"]; } }]; } } else { - NSError *error = - [NSError errorWithDomain:NSCocoaErrorDomain - code:NSURLErrorResourceUnavailable - userInfo:@{NSLocalizedDescriptionKey : @"Video is not recording!"}]; - result(getFlutterError(error)); + [self->_methodChannel invokeMethod:errorMethod arguments:@"Video is not recording!"]; } } @@ -1282,9 +1291,15 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call result:(FlutterResult)re [_camera setUpCaptureSessionForAudio]; result(nil); } else if ([@"startVideoRecording" isEqualToString:call.method]) { - [_camera startVideoRecordingWithResult:result]; + if ([call.arguments[@"maxVideoDuration"] class] != [NSNull class]) { + [_camera startVideoRecordingWithResult:result + maxVideoDuration:((NSNumber *)call.arguments[@"maxVideoDuration"]) + .intValue]; + } else { + [_camera startVideoRecordingWithResult:result maxVideoDuration:0]; + } } else if ([@"stopVideoRecording" isEqualToString:call.method]) { - [_camera stopVideoRecordingWithResult:result]; + [_camera stopVideoRecording:0]; } else if ([@"pauseVideoRecording" isEqualToString:call.method]) { [_camera pauseVideoRecordingWithResult:result]; } else if ([@"resumeVideoRecording" isEqualToString:call.method]) { diff --git a/packages/camera/camera/lib/camera.dart b/packages/camera/camera/lib/camera.dart index d6e32affdd7a..d33ba5fd03dd 100644 --- a/packages/camera/camera/lib/camera.dart +++ b/packages/camera/camera/lib/camera.dart @@ -11,6 +11,7 @@ export 'package:camera_platform_interface/camera_platform_interface.dart' CameraDescription, CameraException, CameraLensDirection, + VideoRecordedEvent, FlashMode, ExposureMode, FocusMode, diff --git a/packages/camera/camera/lib/src/camera_controller.dart b/packages/camera/camera/lib/src/camera_controller.dart index ae79cc4ad367..5f88976552fa 100644 --- a/packages/camera/camera/lib/src/camera_controller.dart +++ b/packages/camera/camera/lib/src/camera_controller.dart @@ -409,7 +409,9 @@ class CameraController extends ValueNotifier { /// /// The video is returned as a [XFile] after calling [stopVideoRecording]. /// Throws a [CameraException] if the capture fails. - Future startVideoRecording() async { + /// + /// TODO: Documentation: when maxVideoDuration listen to Stream with CameraTimeLimitReachedEvent + Future startVideoRecording({Duration maxVideoDuration}) async { if (!value.isInitialized || _isDisposed) { throw CameraException( 'Uninitialized CameraController', @@ -430,8 +432,14 @@ class CameraController extends ValueNotifier { } try { - await CameraPlatform.instance.startVideoRecording(_cameraId); + await CameraPlatform.instance + .startVideoRecording(_cameraId, maxVideoDuration: maxVideoDuration); value = value.copyWith(isRecordingVideo: true, isRecordingPaused: false); + + // Listen for end to update isRecordingVideo in CameraValue. + CameraPlatform.instance.onVideoRecordedEvent(_cameraId).listen((event) { + value = value.copyWith(isRecordingVideo: false); + }); } on PlatformException catch (e) { throw CameraException(e.code, e.message); } @@ -454,9 +462,7 @@ class CameraController extends ValueNotifier { ); } try { - XFile file = await CameraPlatform.instance.stopVideoRecording(_cameraId); - value = value.copyWith(isRecordingVideo: false); - return file; + return CameraPlatform.instance.stopVideoRecording(_cameraId); } on PlatformException catch (e) { throw CameraException(e.code, e.message); } @@ -510,6 +516,19 @@ class CameraController extends ValueNotifier { } } + /// TODO: Documentation + Stream onVideoRecordedEvent() { + if (!value.isInitialized || _isDisposed) { + throw CameraException( + 'Uninitialized CameraController', + 'cameraTimeLimitReachedEventStream was called on uninitialized CameraController', + ); + } + return CameraPlatform.instance + .onVideoRecordedEvent(_cameraId) + .asBroadcastStream(); + } + /// Returns a widget showing a live camera preview. Widget buildPreview() { if (!value.isInitialized || _isDisposed) { diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 0b21497b5462..6bc3d5bf88d1 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -2,13 +2,14 @@ name: camera description: A Flutter plugin for getting information about and controlling the camera on Android and iOS. Supports previewing the camera feed, capturing images, capturing video, and streaming image buffers to dart. -version: 0.6.6 +version: 0.6.7 homepage: https://github.com/flutter/plugins/tree/master/packages/camera/camera dependencies: flutter: sdk: flutter - camera_platform_interface: ^1.5.0 + camera_platform_interface: #^1.5.0 + path: ../camera_platform_interface pedantic: ^1.8.0 dev_dependencies: diff --git a/packages/camera/camera/test/camera_test.dart b/packages/camera/camera/test/camera_test.dart index 1cea609d1741..a7bf43ea2cfd 100644 --- a/packages/camera/camera/test/camera_test.dart +++ b/packages/camera/camera/test/camera_test.dart @@ -1021,6 +1021,24 @@ void main() { .setExposureOffset(cameraController.cameraId, -0.4)) .called(4); }); + + // test('Record video with time limit', () async { + // CameraController cameraController = CameraController( + // CameraDescription( + // name: 'cam', + // lensDirection: CameraLensDirection.back, + // sensorOrientation: 90), + // ResolutionPreset.max); + // await cameraController.initialize(); + // cameraController.onVideoRecordedEvent().listen((VideoRecordedEvent event) { + // debugPrint('VideoRecordedEvent received'); + // }); + // await cameraController.startVideoRecording(maxVideoDuration: Duration(seconds: 2)); + // // OR + // await cameraController.startVideoRecording(); + // await Future.delayed(Duration(milliseconds: 500)); + // await cameraController.stopVideoRecording(); + // }); }); } diff --git a/packages/camera/camera_platform_interface/pubspec.yaml b/packages/camera/camera_platform_interface/pubspec.yaml index 2a8d7ce9abe1..295c42968238 100644 --- a/packages/camera/camera_platform_interface/pubspec.yaml +++ b/packages/camera/camera_platform_interface/pubspec.yaml @@ -12,13 +12,13 @@ dependencies: plugin_platform_interface: ^1.0.1 cross_file: ^0.1.0 stream_transform: ^1.2.0 + pedantic: ^1.8.0 dev_dependencies: flutter_test: sdk: flutter async: ^2.4.2 mockito: ^4.1.1 - pedantic: ^1.8.0 environment: sdk: ">=2.7.0 <3.0.0"