diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 622bd095b02..34f603957ad 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 b9fdd738429..d1364864c99 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 a5f8647afb0..bec35d63411 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 3892452892d..2769b40e2c9 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 aa7483f5567..e1d16aee89d 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 4c3fb3add23..50214d10db3 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 6244aa5a8e3..9824d8d9588 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 d97ce88a58d..dddfa445751 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 d6e32affdd7..d33ba5fd03d 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 80e83c86795..86b48bdccee 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 cebbb334c8f..888ede2beae 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