diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 622bd095b021..34f603957ada 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.7.1 + +* Implemented option to provide a maxVideoDuration to limit a video recording in time. + ## 0.7.0+3 * Clockwise rotation of focus point in android diff --git a/packages/camera/camera/README.md b/packages/camera/camera/README.md index b9fdd7384297..d1364864c99d 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,16 @@ As of version [0.5.0](https://github.com/flutter/plugins/blob/master/packages/ca } ``` +As of version [0.7.1](https://github.com/flutter/plugins/blob/master/packages/camera/camera/CHANGELOG.md#071) the startVideoRecording method can be used with the maxVideoDuration parameter. To do this the result of the recording needs to be retrieved by listening to controller.onVideoRecordedEvent which yields a VideoRecordedEvent when the recording is finished. Like so: +```dart +recordVideo() async { + controller.onVideoRecordedEvent().listen((VideoRecordedEvent event) { + // Handle VideoRecordedEvent + }); + await controller.startVideoRecording(maxVideoDuration: const Duration(seconds: 5)); +} +``` + ### Example Here is a small example flutter app displaying a full screen camera preview. 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 a5f8647afb0b..bec35d634113 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 io.flutter.plugins.camera.CameraUtils.computeBestPreviewSize; import android.annotation.SuppressLint; @@ -105,6 +106,7 @@ public class Camera { private boolean useAutoFocus = true; private Range fpsRange; private PlatformChannel.DeviceOrientation lockedCaptureOrientation; + private Integer maxDurationLimit; private static final HashMap supportedImageFormats; // Current supported outputs @@ -178,19 +180,24 @@ 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( lockedCaptureOrientation == null ? deviceOrientationListener.getMediaOrientation() - : deviceOrientationListener.getMediaOrientation(lockedCaptureOrientation)) - .build(); + : deviceOrientationListener.getMediaOrientation(lockedCaptureOrientation)); + + if (maxVideoDuration != null) { + mediaRecorderBuilder.setMaxVideoDuration(maxVideoDuration); + } + mediaRecorder = mediaRecorderBuilder.build(); } @SuppressLint("MissingPermission") @@ -609,8 +616,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) { @@ -619,10 +627,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; @@ -631,6 +656,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); @@ -639,19 +676,12 @@ 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(); + resetCaptureSession(); + dartMessenger.sendVideoRecordedEvent(videoRecordingFile.getAbsolutePath(), maxDurationLimit); + maxDurationLimit = null; result.success(videoRecordingFile.getAbsolutePath()); 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 3892452892d9..2769b40e2c99 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 @@ -32,7 +32,8 @@ enum DeviceEventType { enum CameraEventType { ERROR("error"), CLOSING("camera_closing"), - INITIALIZED("initialized"); + INITIALIZED("initialized"), + VIDEO_RECORDED("video_recorded"); private final String method; @@ -98,6 +99,17 @@ void sendCameraErrorEvent(@Nullable String description) { }); } + void sendVideoRecordedEvent(String path, Integer maxVideoDuration) { + this.send( + CameraEventType.VIDEO_RECORDED, + new HashMap() { + { + if (path != null) put("path", path); + if (maxVideoDuration != null) put("maxVideoDuration", maxVideoDuration); + } + }); + } + void send(CameraEventType eventType) { send(eventType, new HashMap<>()); } 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 aa7483f55679..e1d16aee89dc 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 @@ -112,7 +112,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 6244aa5a8e37..9824d8d9588f 100644 --- a/packages/camera/camera/example/lib/main.dart +++ b/packages/camera/camera/example/lib/main.dart @@ -726,7 +726,8 @@ 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 d97ce88a58d8..dddfa4457517 100644 --- a/packages/camera/camera/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera/ios/Classes/CameraPlugin.m @@ -782,7 +782,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" @@ -797,6 +798,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 stopVideoRecordingWithResult:nil maxVideoDuration:maxVideoDuration]; + } + }); + } _isRecording = YES; _isRecordingPaused = NO; _videoTimeOffset = CMTimeMake(0, 1); @@ -809,18 +818,30 @@ - (void)startVideoRecordingWithResult:(FlutterResult)result { } } -- (void)stopVideoRecordingWithResult:(FlutterResult)result { +- (void)stopVideoRecordingWithResult:(FlutterResult)result + maxVideoDuration:(int64_t)maxVideoDuration { if (_isRecording) { _isRecording = NO; if (_videoWriter.status != AVAssetWriterStatusUnknown) { [_videoWriter finishWritingWithCompletionHandler:^{ if (self->_videoWriter.status == AVAssetWriterStatusCompleted) { - result(self->_videoRecordingPath); + if (result != nil) { + 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]); + if (result != nil) { + result([FlutterError errorWithCode:@"IOError" + message:@"AVAssetWriter could not finish writing!" + details:nil]); + } + [self->_methodChannel invokeMethod:errorMethod + arguments:@"AVAssetWriter could not finish writing!"]; } }]; } @@ -829,7 +850,10 @@ - (void)stopVideoRecordingWithResult:(FlutterResult)result { [NSError errorWithDomain:NSCocoaErrorDomain code:NSURLErrorResourceUnavailable userInfo:@{NSLocalizedDescriptionKey : @"Video is not recording!"}]; - result(getFlutterError(error)); + if (result != nil) { + result(getFlutterError(error)); + } + [self->_methodChannel invokeMethod:errorMethod arguments:@"Video is not recording!"]; } } @@ -1385,9 +1409,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 stopVideoRecordingWithResult:result maxVideoDuration: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 80e83c867954..86b48bdccee2 100644 --- a/packages/camera/camera/lib/src/camera_controller.dart +++ b/packages/camera/camera/lib/src/camera_controller.dart @@ -430,7 +430,7 @@ class CameraController extends ValueNotifier { /// /// The video is returned as a [XFile] after calling [stopVideoRecording]. /// Throws a [CameraException] if the capture fails. - Future startVideoRecording() async { + Future startVideoRecording({Duration maxVideoDuration}) async { _throwIfNotInitialized("startVideoRecording"); if (value.isRecordingVideo) { throw CameraException( @@ -446,12 +446,19 @@ class CameraController extends ValueNotifier { } try { - await CameraPlatform.instance.startVideoRecording(_cameraId); + await CameraPlatform.instance.startVideoRecording( + _cameraId, + maxVideoDuration: maxVideoDuration, + ); value = value.copyWith( isRecordingVideo: true, isRecordingPaused: false, recordingOrientation: Optional.fromNullable( value.lockedCaptureOrientation ?? value.deviceOrientation)); + // Listen for VideoRecordedEvent to reset isRecordingVideo + CameraPlatform.instance.onVideoRecordedEvent(_cameraId).listen((event) { + value = value.copyWith(isRecordingVideo: false); + }); } on PlatformException catch (e) { throw CameraException(e.code, e.message); } @@ -518,6 +525,19 @@ class CameraController extends ValueNotifier { } } + /// Stream yielding a [VideoRecordedEvent] every time a video stops recording + 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() { _throwIfNotInitialized("buildPreview"); diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index cebbb334c8f2..888ede2beae6 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -2,13 +2,13 @@ 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.7.0+3 +version: 0.7.1 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.6.0 pedantic: ^1.8.0 quiver: ^2.1.5