diff --git a/CHANGELOG.md b/CHANGELOG.md index ff0593ad2c5d..49535b74e79d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.5.4 + +* Add feature to pause and resume video recording. + ## 0.5.3+1 * Fix too large request code for FragmentActivity users. diff --git a/android/src/main/java/io/flutter/plugins/camera/Camera.java b/android/src/main/java/io/flutter/plugins/camera/Camera.java index 763e3b516a62..110c5b690b09 100644 --- a/android/src/main/java/io/flutter/plugins/camera/Camera.java +++ b/android/src/main/java/io/flutter/plugins/camera/Camera.java @@ -388,6 +388,38 @@ public void stopVideoRecording(@NonNull final Result result) { } } + public void pauseVideoRecording(@NonNull final Result result) { + if (!recordingVideo) { + result.success(null); + return; + } + + try { + mediaRecorder.pause(); + } catch (IllegalStateException e) { + result.error("videoRecordingFailed", e.getMessage(), null); + return; + } + + result.success(null); + } + + public void resumeVideoRecording(@NonNull final Result result) { + if (!recordingVideo) { + result.success(null); + return; + } + + try { + mediaRecorder.resume(); + } catch (IllegalStateException e) { + result.error("videoRecordingFailed", e.getMessage(), null); + return; + } + + result.success(null); + } + public void startPreview() throws CameraAccessException { createCaptureSession(CameraDevice.TEMPLATE_PREVIEW, pictureImageReader.getSurface()); } diff --git a/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java b/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java index 69633a499d2a..b3a1da8b1b09 100644 --- a/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java +++ b/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java @@ -112,6 +112,16 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull final Result result) camera.stopVideoRecording(result); break; } + case "pauseVideoRecording": + { + camera.pauseVideoRecording(result); + break; + } + case "resumeVideoRecording": + { + camera.resumeVideoRecording(result); + break; + } case "startImageStream": { try { diff --git a/example/lib/main.dart b/example/lib/main.dart index f66b5b937345..cfdcd1d30bc6 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -214,6 +214,19 @@ class _CameraExampleHomeState extends State ? onVideoRecordButtonPressed : null, ), + IconButton( + icon: controller != null && controller.value.isRecordingPaused + ? Icon(Icons.play_arrow) + : Icon(Icons.pause), + color: Colors.blue, + onPressed: controller != null && + controller.value.isInitialized && + controller.value.isRecordingVideo + ? (controller != null && controller.value.isRecordingPaused + ? onResumeButtonPressed + : onPauseButtonPressed) + : null, + ), IconButton( icon: const Icon(Icons.stop), color: Colors.red, @@ -316,6 +329,20 @@ class _CameraExampleHomeState extends State }); } + void onPauseButtonPressed() { + pauseVideoRecording().then((_) { + if (mounted) setState(() {}); + showInSnackBar('Video recording paused'); + }); + } + + void onResumeButtonPressed() { + resumeVideoRecording().then((_) { + if (mounted) setState(() {}); + showInSnackBar('Video recording resumed'); + }); + } + Future startVideoRecording() async { if (!controller.value.isInitialized) { showInSnackBar('Error: select a camera first.'); @@ -357,6 +384,32 @@ class _CameraExampleHomeState extends State await _startVideoPlayer(); } + Future pauseVideoRecording() async { + if (!controller.value.isRecordingVideo) { + return null; + } + + try { + await controller.pauseVideoRecording(); + } on CameraException catch (e) { + _showCameraException(e); + return null; + } + } + + Future resumeVideoRecording() async { + if (!controller.value.isRecordingVideo) { + return null; + } + + try { + await controller.resumeVideoRecording(); + } on CameraException catch (e) { + _showCameraException(e); + return null; + } + } + Future _startVideoPlayer() async { final VideoPlayerController vcontroller = VideoPlayerController.file(File(videoPath)); diff --git a/example/test_driver/camera.dart b/example/test_driver/camera.dart index 7d59016ff0b1..d68b8c5ba1fc 100644 --- a/example/test_driver/camera.dart +++ b/example/test_driver/camera.dart @@ -143,4 +143,60 @@ void main() { } } }); + + test('Pause and resume video recording', () async { + final List cameras = await availableCameras(); + if (cameras.isEmpty) { + return; + } + + final CameraController controller = CameraController( + cameras[0], + ResolutionPreset.low, + enableAudio: false, + ); + + await controller.initialize(); + await controller.prepareForVideoRecording(); + + final String filePath = + '${testDir.path}/${DateTime.now().millisecondsSinceEpoch}.mp4'; + + int startPause; + int timePaused = 0; + + await controller.startVideoRecording(filePath); + final int recordingStart = DateTime.now().millisecondsSinceEpoch; + sleep(const Duration(milliseconds: 500)); + + await controller.pauseVideoRecording(); + startPause = DateTime.now().millisecondsSinceEpoch; + sleep(const Duration(milliseconds: 500)); + await controller.resumeVideoRecording(); + timePaused += DateTime.now().millisecondsSinceEpoch - startPause; + + sleep(const Duration(milliseconds: 500)); + + await controller.pauseVideoRecording(); + startPause = DateTime.now().millisecondsSinceEpoch; + sleep(const Duration(milliseconds: 500)); + await controller.resumeVideoRecording(); + timePaused += DateTime.now().millisecondsSinceEpoch - startPause; + + sleep(const Duration(milliseconds: 500)); + + await controller.stopVideoRecording(); + final int recordingTime = + DateTime.now().millisecondsSinceEpoch - recordingStart; + + final File videoFile = File(filePath); + final VideoPlayerController videoController = VideoPlayerController.file( + videoFile, + ); + await videoController.initialize(); + final int duration = videoController.value.duration.inMilliseconds; + await videoController.dispose(); + + expect(duration, lessThan(recordingTime - timePaused)); + }); } diff --git a/ios/Classes/CameraPlugin.m b/ios/Classes/CameraPlugin.m index 8a08c435861a..42cdb6d5fdf9 100644 --- a/ios/Classes/CameraPlugin.m +++ b/ios/Classes/CameraPlugin.m @@ -180,10 +180,18 @@ @interface FLTCam : NSObject 0) { + currentSampleTime = CMTimeAdd(currentSampleTime, dur); + } + + if (_audioIsDisconnected) { + _audioIsDisconnected = NO; + + if (_audioTimeOffset.value == 0) { + _audioTimeOffset = CMTimeSubtract(currentSampleTime, _lastAudioSampleTime); + } else { + CMTime offset = CMTimeSubtract(currentSampleTime, _lastAudioSampleTime); + _audioTimeOffset = CMTimeAdd(_audioTimeOffset, offset); + } + + return; + } + + _lastAudioSampleTime = currentSampleTime; + + if (_audioTimeOffset.value != 0) { + CFRelease(sampleBuffer); + sampleBuffer = [self adjustTime:sampleBuffer by:_audioTimeOffset]; + } + [self newAudioSample:sampleBuffer]; } + + CFRelease(sampleBuffer); + } +} + +- (CMSampleBufferRef)adjustTime:(CMSampleBufferRef)sample by:(CMTime)offset { + CMItemCount count; + CMSampleBufferGetSampleTimingInfoArray(sample, 0, nil, &count); + CMSampleTimingInfo *pInfo = malloc(sizeof(CMSampleTimingInfo) * count); + CMSampleBufferGetSampleTimingInfoArray(sample, count, pInfo, &count); + for (CMItemCount i = 0; i < count; i++) { + pInfo[i].decodeTimeStamp = CMTimeSubtract(pInfo[i].decodeTimeStamp, offset); + pInfo[i].presentationTimeStamp = CMTimeSubtract(pInfo[i].presentationTimeStamp, offset); } + CMSampleBufferRef sout; + CMSampleBufferCreateCopyWithNewTiming(nil, sample, count, pInfo, &sout); + free(pInfo); + return sout; } - (void)newVideoSample:(CMSampleBufferRef)sampleBuffer { @@ -526,6 +598,11 @@ - (void)startVideoRecordingAtPath:(NSString *)path result:(FlutterResult)result return; } _isRecording = YES; + _isRecordingPaused = NO; + _videoTimeOffset = CMTimeMake(0, 1); + _audioTimeOffset = CMTimeMake(0, 1); + _videoIsDisconnected = NO; + _audioIsDisconnected = NO; result(nil); } else { _eventSink(@{@"event" : @"error", @"errorDescription" : @"Video is already recording!"}); @@ -556,6 +633,16 @@ - (void)stopVideoRecordingWithResult:(FlutterResult)result { } } +- (void)pauseVideoRecording { + _isRecordingPaused = YES; + _videoIsDisconnected = YES; + _audioIsDisconnected = YES; +} + +- (void)resumeVideoRecording { + _isRecordingPaused = NO; +} + - (void)startImageStreamWithMessenger:(NSObject *)messenger { if (!_isStreamingImages) { FlutterEventChannel *eventChannel = @@ -608,6 +695,13 @@ - (BOOL)setupWriterForPath:(NSString *)path { nil]; _videoWriterInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:videoSettings]; + + _videoAdaptor = [AVAssetWriterInputPixelBufferAdaptor + assetWriterInputPixelBufferAdaptorWithAssetWriterInput:_videoWriterInput + sourcePixelBufferAttributes:@{ + (NSString *)kCVPixelBufferPixelFormatTypeKey : @(videoFormat) + }]; + NSParameterAssert(_videoWriterInput); _videoWriterInput.expectsMediaDataInRealTime = YES; @@ -777,6 +871,12 @@ - (void)handleMethodCallAsync:(FlutterMethodCall *)call result:(FlutterResult)re } else if ([@"stopImageStream" isEqualToString:call.method]) { [_camera stopImageStream]; result(nil); + } else if ([@"pauseVideoRecording" isEqualToString:call.method]) { + [_camera pauseVideoRecording]; + result(nil); + } else if ([@"resumeVideoRecording" isEqualToString:call.method]) { + [_camera resumeVideoRecording]; + result(nil); } else { NSDictionary *argsMap = call.arguments; NSUInteger textureId = ((NSNumber *)argsMap[@"textureId"]).unsignedIntegerValue; diff --git a/lib/camera.dart b/lib/camera.dart index cd2b3991bbb7..ee1892c4cbc0 100644 --- a/lib/camera.dart +++ b/lib/camera.dart @@ -157,14 +157,17 @@ class CameraValue { this.isRecordingVideo, this.isTakingPicture, this.isStreamingImages, - }); + bool isRecordingPaused, + }) : _isRecordingPaused = isRecordingPaused; const CameraValue.uninitialized() : this( - isInitialized: false, - isRecordingVideo: false, - isTakingPicture: false, - isStreamingImages: false); + isInitialized: false, + isRecordingVideo: false, + isTakingPicture: false, + isStreamingImages: false, + isRecordingPaused: false, + ); /// True after [CameraController.initialize] has completed successfully. final bool isInitialized; @@ -178,6 +181,11 @@ class CameraValue { /// True when images from the camera are being streamed. final bool isStreamingImages; + final bool _isRecordingPaused; + + /// True when camera [isRecordingVideo] and recording is paused. + bool get isRecordingPaused => isRecordingVideo && _isRecordingPaused; + final String errorDescription; /// The size of the preview in pixels. @@ -199,6 +207,7 @@ class CameraValue { bool isStreamingImages, String errorDescription, Size previewSize, + bool isRecordingPaused, }) { return CameraValue( isInitialized: isInitialized ?? this.isInitialized, @@ -207,6 +216,7 @@ class CameraValue { isRecordingVideo: isRecordingVideo ?? this.isRecordingVideo, isTakingPicture: isTakingPicture ?? this.isTakingPicture, isStreamingImages: isStreamingImages ?? this.isStreamingImages, + isRecordingPaused: isRecordingPaused ?? _isRecordingPaused, ); } @@ -473,7 +483,7 @@ class CameraController extends ValueNotifier { 'startVideoRecording', {'textureId': _textureId, 'filePath': filePath}, ); - value = value.copyWith(isRecordingVideo: true); + value = value.copyWith(isRecordingVideo: true, isRecordingPaused: false); } on PlatformException catch (e) { throw CameraException(e.code, e.message); } @@ -504,6 +514,60 @@ class CameraController extends ValueNotifier { } } + /// Pause video recording. + /// + /// This feature is only available on iOS and Android sdk 24+. + Future pauseVideoRecording() async { + if (!value.isInitialized || _isDisposed) { + throw CameraException( + 'Uninitialized CameraController', + 'pauseVideoRecording was called on uninitialized CameraController', + ); + } + if (!value.isRecordingVideo) { + throw CameraException( + 'No video is recording', + 'pauseVideoRecording was called when no video is recording.', + ); + } + try { + value = value.copyWith(isRecordingPaused: true); + await _channel.invokeMethod( + 'pauseVideoRecording', + {'textureId': _textureId}, + ); + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } + } + + /// Resume video recording after pausing. + /// + /// This feature is only available on iOS and Android sdk 24+. + Future resumeVideoRecording() async { + if (!value.isInitialized || _isDisposed) { + throw CameraException( + 'Uninitialized CameraController', + 'resumeVideoRecording was called on uninitialized CameraController', + ); + } + if (!value.isRecordingVideo) { + throw CameraException( + 'No video is recording', + 'resumeVideoRecording was called when no video is recording.', + ); + } + try { + value = value.copyWith(isRecordingPaused: false); + await _channel.invokeMethod( + 'resumeVideoRecording', + {'textureId': _textureId}, + ); + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } + } + /// Releases the resources of this camera. @override Future dispose() async { diff --git a/pubspec.yaml b/pubspec.yaml index 3a82ee425e34..e94d4bd979f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ 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.5.3+1 +version: 0.5.4 authors: - Flutter Team