diff --git a/packages/camera/CHANGELOG.md b/packages/camera/CHANGELOG.md index 71f7240a0016..f3c200c35524 100644 --- a/packages/camera/CHANGELOG.md +++ b/packages/camera/CHANGELOG.md @@ -1,3 +1,22 @@ +## 0.2.0 + +* Added support for video recording. +* Changed the example app to add video recording. + +A lot of **breaking changes** in this version: + +Getter changes: + - Removed `isStarted` + - Renamed `initialized` to `isInitialized` + - Added `isRecordingVideo` + +Method changes: + - Renamed `capture` to `takePicture` + - Removed `start` (the preview starts automatically when `initialize` is called) + - Added `startVideoRecording(String filePath)` + - Removed `stop` (the preview stops automatically when `dispose` is called) + - Added `stopVideoRecording` + ## 0.1.2 * Fix Dart 2 runtime errors. diff --git a/packages/camera/README.md b/packages/camera/README.md index a6b62df2d686..2e224df6e2d8 100644 --- a/packages/camera/README.md +++ b/packages/camera/README.md @@ -15,13 +15,18 @@ First, add `camera` as a [dependency in your pubspec.yaml file](https://flutter. ### iOS -Add a row to the `ios/Runner/Info.plist` of your app with the key `Privacy - Camera Usage Description` and a usage description. +Add two rows to the `ios/Runner/Info.plist`: + +* one with the key `Privacy - Camera Usage Description` and a usage description. +* and one with the key `Privacy - Microphone Usage Description` and a usage description. Or in text format add the key: ```xml NSCameraUsageDescription Can I use the camera please? +NSMicrophoneUsageDescription +Can I use the mic please? ``` ### Android diff --git a/packages/camera/android/src/main/AndroidManifest.xml b/packages/camera/android/src/main/AndroidManifest.xml index 449d282b9476..d80d364b4237 100644 --- a/packages/camera/android/src/main/AndroidManifest.xml +++ b/packages/camera/android/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + diff --git a/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java b/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java index 472884dcd6af..1d4bedefbbf4 100644 --- a/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java +++ b/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraPlugin.java @@ -1,7 +1,6 @@ package io.flutter.plugins.camera; import android.Manifest; -import android.annotation.SuppressLint; import android.app.Activity; import android.app.Application; import android.content.Context; @@ -19,9 +18,11 @@ import android.hardware.camera2.params.StreamConfigurationMap; import android.media.Image; import android.media.ImageReader; +import android.media.MediaRecorder; import android.os.Build; import android.os.Bundle; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.util.Size; import android.util.SparseIntArray; import android.view.Surface; @@ -47,33 +48,34 @@ public class CameraPlugin implements MethodCallHandler { - private static final int cameraRequestId = 513469796; - private static final SparseIntArray ORIENTATIONS = new SparseIntArray(); - private static CameraManager cameraManager; - - @SuppressLint("UseSparseArrays") - private static Map cams = new HashMap<>(); - - static { - ORIENTATIONS.append(Surface.ROTATION_0, 0); - ORIENTATIONS.append(Surface.ROTATION_90, 90); - ORIENTATIONS.append(Surface.ROTATION_180, 180); - ORIENTATIONS.append(Surface.ROTATION_270, 270); - } + private static final int CAMERA_REQUEST_ID = 513469796; + private static final String TAG = "CameraPlugin"; + private static final SparseIntArray ORIENTATIONS = + new SparseIntArray() { + { + append(Surface.ROTATION_0, 0); + append(Surface.ROTATION_90, 90); + append(Surface.ROTATION_180, 180); + append(Surface.ROTATION_270, 270); + } + }; + private static CameraManager cameraManager; private final FlutterView view; + private Camera camera; private Activity activity; private Registrar registrar; - // The code to run after requesting the permission. + // The code to run after requesting camera permissions. private Runnable cameraPermissionContinuation; + private boolean requestingPermission; private CameraPlugin(Registrar registrar, FlutterView view, Activity activity) { this.registrar = registrar; - - registrar.addRequestPermissionsResultListener(new CameraRequestPermissionsListener()); this.view = view; this.activity = activity; + registrar.addRequestPermissionsResultListener(new CameraRequestPermissionsListener()); + activity .getApplication() .registerActivityLifecycleCallbacks( @@ -86,9 +88,13 @@ public void onActivityStarted(Activity activity) {} @Override public void onActivityResumed(Activity activity) { + if (requestingPermission) { + requestingPermission = false; + return; + } if (activity == CameraPlugin.this.activity) { - for (Cam cam : cams.values()) { - cam.resume(); + if (camera != null) { + camera.open(null); } } } @@ -96,8 +102,8 @@ public void onActivityResumed(Activity activity) { @Override public void onActivityPaused(Activity activity) { if (activity == CameraPlugin.this.activity) { - for (Cam cam : cams.values()) { - cam.pause(); + if (camera != null) { + camera.close(); } } } @@ -105,7 +111,9 @@ public void onActivityPaused(Activity activity) { @Override public void onActivityStopped(Activity activity) { if (activity == CameraPlugin.this.activity) { - disposeAllCams(); + if (camera != null) { + camera.close(); + } } } @@ -120,59 +128,23 @@ public void onActivityDestroyed(Activity activity) {} public static void registerWith(Registrar registrar) { final MethodChannel channel = new MethodChannel(registrar.messenger(), "plugins.flutter.io/camera"); + cameraManager = (CameraManager) registrar.activity().getSystemService(Context.CAMERA_SERVICE); channel.setMethodCallHandler( new CameraPlugin(registrar, registrar.view(), registrar.activity())); } - private Size getBestPreviewSize( - StreamConfigurationMap streamConfigurationMap, Size minPreviewSize, Size captureSize) { - Size[] sizes = streamConfigurationMap.getOutputSizes(SurfaceTexture.class); - List goodEnough = new ArrayList<>(); - for (Size s : sizes) { - if (s.getHeight() * captureSize.getWidth() == s.getWidth() * captureSize.getHeight() - && minPreviewSize.getWidth() < s.getWidth() - && minPreviewSize.getHeight() < s.getHeight()) { - goodEnough.add(s); - } - } - if (goodEnough.isEmpty()) { - return sizes[0]; - } - return Collections.min(goodEnough, new CompareSizesByArea()); - } - - private Size getBestCaptureSize(StreamConfigurationMap streamConfigurationMap) { - // For still image captures, we use the largest available size. - return Collections.max( - Arrays.asList(streamConfigurationMap.getOutputSizes(ImageFormat.JPEG)), - new CompareSizesByArea()); - } - - private long textureIdOfCall(MethodCall call) { - return ((Number) call.argument("textureId")).longValue(); - } - - private Cam getCamOfCall(MethodCall call) { - return cams.get(textureIdOfCall(call)); - } - - private void disposeAllCams() { - for (Cam cam : cams.values()) { - cam.dispose(); - } - cams.clear(); - } - @Override public void onMethodCall(MethodCall call, final Result result) { switch (call.method) { case "init": - disposeAllCams(); + if (camera != null) { + camera.close(); + } result.success(null); break; - case "list": + case "availableCameras": try { String[] cameraNames = cameraManager.getCameraIdList(); List> cameras = new ArrayList<>(); @@ -182,8 +154,8 @@ public void onMethodCall(MethodCall call, final Result result) { cameraManager.getCameraCharacteristics(cameraName); details.put("name", cameraName); @SuppressWarnings("ConstantConditions") - int lens_facing = characteristics.get(CameraCharacteristics.LENS_FACING); - switch (lens_facing) { + int lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING); + switch (lensFacing) { case CameraMetadata.LENS_FACING_FRONT: details.put("lensFacing", "front"); break; @@ -201,46 +173,37 @@ public void onMethodCall(MethodCall call, final Result result) { result.error("cameraAccess", e.getMessage(), null); } break; - case "create": + case "initialize": { - FlutterView.SurfaceTextureEntry surfaceTexture = view.createSurfaceTexture(); - final EventChannel eventChannel = - new EventChannel( - registrar.messenger(), - "flutter.io/cameraPlugin/cameraEvents" + surfaceTexture.id()); String cameraName = call.argument("cameraName"); String resolutionPreset = call.argument("resolutionPreset"); - Cam cam = new Cam(eventChannel, surfaceTexture, cameraName, resolutionPreset, result); - cams.put(cam.getTextureId(), cam); + if (camera != null) { + camera.close(); + } + camera = new Camera(cameraName, resolutionPreset, result); break; } - case "start": + case "takePicture": { - Cam cam = getCamOfCall(call); - cam.start(); - result.success(null); + camera.takePicture((String) call.argument("path"), result); break; } - case "capture": + case "startVideoRecording": { - Cam cam = getCamOfCall(call); - cam.capture((String) call.argument("path"), result); + final String filePath = call.argument("filePath"); + camera.startVideoRecording(filePath, result); break; } - case "stop": + case "stopVideoRecording": { - Cam cam = getCamOfCall(call); - cam.stop(); - result.success(null); + camera.stopVideoRecording(result); break; } case "dispose": { - Cam cam = getCamOfCall(call); - if (cam != null) { - cam.dispose(); + if (camera != null) { + camera.dispose(); } - cams.remove(textureIdOfCall(call)); result.success(null); break; } @@ -253,7 +216,7 @@ public void onMethodCall(MethodCall call, final Result result) { private static class CompareSizesByArea implements Comparator { @Override public int compare(Size lhs, Size rhs) { - // We cast here to ensure the multiplications won't overflow + // We cast here to ensure the multiplications won't overflow. return Long.signum( (long) lhs.getWidth() * lhs.getHeight() - (long) rhs.getWidth() * rhs.getHeight()); } @@ -263,7 +226,7 @@ private class CameraRequestPermissionsListener implements PluginRegistry.RequestPermissionsResultListener { @Override public boolean onRequestPermissionsResult(int id, String[] permissions, int[] grantResults) { - if (id == cameraRequestId) { + if (id == CAMERA_REQUEST_ID) { cameraPermissionContinuation.run(); return true; } @@ -271,33 +234,30 @@ public boolean onRequestPermissionsResult(int id, String[] permissions, int[] gr } } - private class Cam { + private class Camera { private final FlutterView.SurfaceTextureEntry textureEntry; private CameraDevice cameraDevice; - private Surface previewSurface; private CameraCaptureSession cameraCaptureSession; private EventChannel.EventSink eventSink; private ImageReader imageReader; - private boolean started = false; private int sensorOrientation; - private boolean facingFront; + private boolean isFrontFacing; private String cameraName; - private boolean initialized = false; private Size captureSize; private Size previewSize; + private CaptureRequest.Builder captureRequestBuilder; + private Size videoSize; + private MediaRecorder mediaRecorder; + private boolean recordingVideo; - Cam( - final EventChannel eventChannel, - final FlutterView.SurfaceTextureEntry textureEntry, - final String cameraName, - final String resolutionPreset, - final Result result) { + Camera(final String cameraName, final String resolutionPreset, @NonNull final Result result) { - this.textureEntry = textureEntry; this.cameraName = cameraName; - try { - CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraName); + textureEntry = view.createSurfaceTexture(); + + registerEventChannel(); + try { Size minPreviewSize; switch (resolutionPreset) { case "high": @@ -312,28 +272,19 @@ private class Cam { default: throw new IllegalArgumentException("Unknown preset: " + resolutionPreset); } + + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraName); StreamConfigurationMap streamConfigurationMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); - captureSize = getBestCaptureSize(streamConfigurationMap); - previewSize = getBestPreviewSize(streamConfigurationMap, minPreviewSize, captureSize); - imageReader = - ImageReader.newInstance( - captureSize.getWidth(), captureSize.getHeight(), ImageFormat.JPEG, 2); - SurfaceTexture surfaceTexture = textureEntry.surfaceTexture(); - surfaceTexture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight()); - previewSurface = new Surface(surfaceTexture); - eventChannel.setStreamHandler( - new EventChannel.StreamHandler() { - @Override - public void onListen(Object arguments, EventChannel.EventSink eventSink) { - Cam.this.eventSink = eventSink; - } + //noinspection ConstantConditions + sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION); + //noinspection ConstantConditions + isFrontFacing = + characteristics.get(CameraCharacteristics.LENS_FACING) + == CameraMetadata.LENS_FACING_FRONT; + computeBestCaptureSize(streamConfigurationMap); + computeBestPreviewAndRecordingSize(streamConfigurationMap, minPreviewSize, captureSize); - @Override - public void onCancel(Object arguments) { - Cam.this.eventSink = null; - } - }); if (cameraPermissionContinuation != null) { result.error("cameraPermission", "Camera permission request ongoing", null); } @@ -342,206 +293,213 @@ public void onCancel(Object arguments) { @Override public void run() { cameraPermissionContinuation = null; - openCamera(result); + if (!hasCameraPermission()) { + result.error( + "cameraPermission", "MediaRecorderCamera permission not granted", null); + return; + } + if (!hasAudioPermission()) { + result.error( + "cameraPermission", "MediaRecorderAudio permission not granted", null); + return; + } + open(result); } }; - if (hasCameraPermission()) { + requestingPermission = false; + if (hasCameraPermission() && hasAudioPermission()) { cameraPermissionContinuation.run(); } else { - activity.requestPermissions(new String[] {Manifest.permission.CAMERA}, cameraRequestId); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + requestingPermission = true; + registrar + .activity() + .requestPermissions( + new String[] {Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO}, + CAMERA_REQUEST_ID); + } } } catch (CameraAccessException e) { - result.error("cameraAccess", e.getMessage(), null); + result.error("CameraAccess", e.getMessage(), null); + } catch (IllegalArgumentException e) { + result.error("IllegalArgumentException", e.getMessage(), null); } } + private void registerEventChannel() { + new EventChannel( + registrar.messenger(), "flutter.io/cameraPlugin/cameraEvents" + textureEntry.id()) + .setStreamHandler( + new EventChannel.StreamHandler() { + @Override + public void onListen(Object arguments, EventChannel.EventSink eventSink) { + Camera.this.eventSink = eventSink; + } + + @Override + public void onCancel(Object arguments) { + Camera.this.eventSink = null; + } + }); + } + private boolean hasCameraPermission() { return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || activity.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED; } - private void openCamera(final Result result) { + private boolean hasAudioPermission() { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.M + || registrar.activity().checkSelfPermission(Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED; + } + + private void computeBestPreviewAndRecordingSize( + StreamConfigurationMap streamConfigurationMap, Size minPreviewSize, Size captureSize) { + Size[] sizes = streamConfigurationMap.getOutputSizes(SurfaceTexture.class); + float captureSizeRatio = (float) captureSize.getWidth() / captureSize.getHeight(); + List goodEnough = new ArrayList<>(); + for (Size s : sizes) { + if ((float) s.getWidth() / s.getHeight() == captureSizeRatio + && minPreviewSize.getWidth() < s.getWidth() + && minPreviewSize.getHeight() < s.getHeight()) { + goodEnough.add(s); + } + } + + Collections.sort(goodEnough, new CompareSizesByArea()); + + if (goodEnough.isEmpty()) { + previewSize = sizes[0]; + videoSize = sizes[0]; + } else { + previewSize = goodEnough.get(0); + + // Video capture size should not be greater than 1080 because MediaRecorder cannot handle higher resolutions. + videoSize = goodEnough.get(0); + for (int i = goodEnough.size() - 1; i >= 0; i--) { + if (goodEnough.get(i).getHeight() <= 1080) { + videoSize = goodEnough.get(i); + break; + } + } + } + } + + private void computeBestCaptureSize(StreamConfigurationMap streamConfigurationMap) { + // For still image captures, we use the largest available size. + captureSize = + Collections.max( + Arrays.asList(streamConfigurationMap.getOutputSizes(ImageFormat.JPEG)), + new CompareSizesByArea()); + } + + private void prepareMediaRecorder(String outputFilePath) throws IOException { + if (mediaRecorder != null) { + mediaRecorder.release(); + } + mediaRecorder = new MediaRecorder(); + mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); + mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE); + mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); + mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); + mediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264); + mediaRecorder.setVideoEncodingBitRate(1024 * 1000); + mediaRecorder.setAudioSamplingRate(16000); + mediaRecorder.setVideoFrameRate(27); + mediaRecorder.setVideoSize(videoSize.getWidth(), videoSize.getHeight()); + mediaRecorder.setOutputFile(outputFilePath); + + int displayRotation = activity.getWindowManager().getDefaultDisplay().getRotation(); + int displayOrientation = ORIENTATIONS.get(displayRotation); + if (isFrontFacing) displayOrientation = -displayOrientation; + mediaRecorder.setOrientationHint((displayOrientation + sensorOrientation) % 360); + + mediaRecorder.prepare(); + } + + private void open(@Nullable final Result result) { if (!hasCameraPermission()) { - result.error("cameraPermission", "Camera permission not granted", null); + if (result != null) result.error("cameraPermission", "Camera permission not granted", null); } else { try { - CameraCharacteristics characteristics = - cameraManager.getCameraCharacteristics(cameraName); - //noinspection ConstantConditions - sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION); - //noinspection ConstantConditions - facingFront = - characteristics.get(CameraCharacteristics.LENS_FACING) - == CameraMetadata.LENS_FACING_FRONT; + imageReader = + ImageReader.newInstance( + captureSize.getWidth(), captureSize.getHeight(), ImageFormat.JPEG, 2); cameraManager.openCamera( cameraName, new CameraDevice.StateCallback() { @Override public void onOpened(@NonNull CameraDevice cameraDevice) { - Cam.this.cameraDevice = cameraDevice; - List surfaceList = new ArrayList<>(); - surfaceList.add(previewSurface); - surfaceList.add(imageReader.getSurface()); - + Camera.this.cameraDevice = cameraDevice; try { - cameraDevice.createCaptureSession( - surfaceList, - new CameraCaptureSession.StateCallback() { - @Override - public void onConfigured( - @NonNull CameraCaptureSession cameraCaptureSession) { - Cam.this.cameraCaptureSession = cameraCaptureSession; - initialized = true; - Map reply = new HashMap<>(); - reply.put("textureId", textureEntry.id()); - reply.put("previewWidth", previewSize.getWidth()); - reply.put("previewHeight", previewSize.getHeight()); - result.success(reply); - } - - @Override - public void onConfigureFailed( - @NonNull CameraCaptureSession cameraCaptureSession) { - result.error( - "configureFailed", "Failed to configure camera session", null); - } - }, - null); + startPreview(); } catch (CameraAccessException e) { - result.error("cameraAccess", e.getMessage(), null); + if (result != null) result.error("CameraAccess", e.getMessage(), null); + } + + if (result != null) { + Map reply = new HashMap<>(); + reply.put("textureId", textureEntry.id()); + reply.put("previewWidth", previewSize.getWidth()); + reply.put("previewHeight", previewSize.getHeight()); + result.success(reply); } } @Override - public void onDisconnected(@NonNull CameraDevice cameraDevice) { + public void onClosed(@NonNull CameraDevice camera) { if (eventSink != null) { Map event = new HashMap<>(); - event.put("eventType", "error"); - event.put("errorDescription", "The camera was disconnected"); + event.put("eventType", "cameraClosing"); eventSink.success(event); } + super.onClosed(camera); + } + + @Override + public void onDisconnected(@NonNull CameraDevice cameraDevice) { + cameraDevice.close(); + Camera.this.cameraDevice = null; + sendErrorEvent("The camera was disconnected."); } @Override public void onError(@NonNull CameraDevice cameraDevice, int errorCode) { - if (eventSink != null) { - String errorDescription; - switch (errorCode) { - case ERROR_CAMERA_IN_USE: - errorDescription = "The camera device is in use already."; - break; - case ERROR_MAX_CAMERAS_IN_USE: - errorDescription = "Max cameras in use"; - break; - case ERROR_CAMERA_DISABLED: - errorDescription = - "The camera device could not be opened due to a device policy."; - break; - case ERROR_CAMERA_DEVICE: - errorDescription = "The camera device has encountered a fatal error"; - break; - case ERROR_CAMERA_SERVICE: - errorDescription = "The camera service has encountered a fatal error."; - break; - default: - errorDescription = "Unknown camera error"; - } - Map event = new HashMap<>(); - event.put("eventType", "error"); - event.put("errorDescription", errorDescription); - eventSink.success(event); + cameraDevice.close(); + Camera.this.cameraDevice = null; + String errorDescription; + switch (errorCode) { + case ERROR_CAMERA_IN_USE: + errorDescription = "The camera device is in use already."; + break; + case ERROR_MAX_CAMERAS_IN_USE: + errorDescription = "Max cameras in use"; + break; + case ERROR_CAMERA_DISABLED: + errorDescription = + "The camera device could not be opened due to a device policy."; + break; + case ERROR_CAMERA_DEVICE: + errorDescription = "The camera device has encountered a fatal error"; + break; + case ERROR_CAMERA_SERVICE: + errorDescription = "The camera service has encountered a fatal error."; + break; + default: + errorDescription = "Unknown camera error"; } + sendErrorEvent(errorDescription); } }, null); } catch (CameraAccessException e) { - result.error("cameraAccess", e.getMessage(), null); + if (result != null) result.error("cameraAccess", e.getMessage(), null); } } } - void start() { - if (!initialized) { - return; - } - try { - final CaptureRequest.Builder previewRequestBuilder = - cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW); - previewRequestBuilder.set( - CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE); - previewRequestBuilder.addTarget(previewSurface); - CaptureRequest previewRequest = previewRequestBuilder.build(); - cameraCaptureSession.setRepeatingRequest( - previewRequest, - new CameraCaptureSession.CaptureCallback() { - @Override - public void onCaptureBufferLost( - @NonNull CameraCaptureSession session, - @NonNull CaptureRequest request, - @NonNull Surface target, - long frameNumber) { - super.onCaptureBufferLost(session, request, target, frameNumber); - if (eventSink != null) { - eventSink.success("lost buffer"); - } - } - }, - null); - } catch (CameraAccessException exception) { - Map event = new HashMap<>(); - event.put("eventType", "error"); - event.put("errorDescription", "Unable to start camera"); - eventSink.success(event); - } - started = true; - } - - void pause() { - if (!initialized) { - return; - } - if (started && cameraCaptureSession != null) { - try { - cameraCaptureSession.stopRepeating(); - } catch (CameraAccessException e) { - Map event = new HashMap<>(); - event.put("eventType", "error"); - event.put("errorDescription", "Unable to pause camera"); - eventSink.success(event); - } - } - if (cameraCaptureSession != null) { - cameraCaptureSession.close(); - cameraCaptureSession = null; - } - if (cameraDevice != null) { - cameraDevice.close(); - cameraDevice = null; - } - } - - void resume() { - if (!initialized) { - return; - } - openCamera( - new Result() { - @Override - public void success(Object o) { - if (started) { - start(); - } - } - - @Override - public void error(String s, String s1, Object o) {} - - @Override - public void notImplemented() {} - }); - } - private void writeToFile(ByteBuffer buffer, File file) throws IOException { try (FileOutputStream outputStream = new FileOutputStream(file)) { while (0 < buffer.remaining()) { @@ -550,24 +508,27 @@ private void writeToFile(ByteBuffer buffer, File file) throws IOException { } } - void capture(String path, final Result result) { - final File file = new File(path); + private void takePicture(String filePath, @NonNull final Result result) { + final File file = new File(filePath); + + if (file.exists()) { + result.error( + "fileExists", + "File at path '" + filePath + "' already exists. Cannot overwrite.", + null); + return; + } + imageReader.setOnImageAvailableListener( new ImageReader.OnImageAvailableListener() { @Override public void onImageAvailable(ImageReader reader) { - boolean success = false; try (Image image = reader.acquireLatestImage()) { ByteBuffer buffer = image.getPlanes()[0].getBuffer(); writeToFile(buffer, file); - success = true; result.success(null); } catch (IOException e) { - // Theoretically image.close() could throw, so only report the error - // if we have not successfully written the file. - if (!success) { - result.error("IOError", "Failed saving image", null); - } + result.error("IOError", "Failed saving image", null); } } }, @@ -579,8 +540,7 @@ public void onImageAvailable(ImageReader reader) { captureBuilder.addTarget(imageReader.getSurface()); int displayRotation = activity.getWindowManager().getDefaultDisplay().getRotation(); int displayOrientation = ORIENTATIONS.get(displayRotation); - if (facingFront) displayOrientation = -displayOrientation; - + if (isFrontFacing) displayOrientation = -displayOrientation; captureBuilder.set( CaptureRequest.JPEG_ORIENTATION, (-displayOrientation + sensorOrientation) % 360); @@ -612,31 +572,167 @@ public void onCaptureFailed( } } - void stop() { + private void startVideoRecording(String filePath, @NonNull final Result result) { + if (cameraDevice == null) { + result.error("configureFailed", "Camera was closed during configuration.", null); + return; + } + if (new File(filePath).exists()) { + result.error( + "fileExists", + "File at path '" + filePath + "' already exists. Cannot overwrite.", + null); + return; + } try { - cameraCaptureSession.stopRepeating(); - started = false; - } catch (CameraAccessException e) { + closeCaptureSession(); + prepareMediaRecorder(filePath); + + recordingVideo = true; + + SurfaceTexture surfaceTexture = textureEntry.surfaceTexture(); + surfaceTexture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight()); + captureRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD); + + List surfaces = new ArrayList<>(); + + Surface previewSurface = new Surface(surfaceTexture); + surfaces.add(previewSurface); + captureRequestBuilder.addTarget(previewSurface); + + Surface recorderSurface = mediaRecorder.getSurface(); + surfaces.add(recorderSurface); + captureRequestBuilder.addTarget(recorderSurface); + + cameraDevice.createCaptureSession( + surfaces, + new CameraCaptureSession.StateCallback() { + @Override + public void onConfigured(@NonNull CameraCaptureSession cameraCaptureSession) { + try { + if (cameraDevice == null) { + result.error("configureFailed", "Camera was closed during configuration", null); + return; + } + Camera.this.cameraCaptureSession = cameraCaptureSession; + captureRequestBuilder.set( + CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO); + cameraCaptureSession.setRepeatingRequest( + captureRequestBuilder.build(), null, null); + mediaRecorder.start(); + result.success(null); + } catch (CameraAccessException e) { + result.error("cameraAccess", e.getMessage(), null); + } + } + + @Override + public void onConfigureFailed(@NonNull CameraCaptureSession cameraCaptureSession) { + result.error("configureFailed", "Failed to configure camera session", null); + } + }, + null); + } catch (CameraAccessException | IOException e) { + result.error("videoRecordingFailed", e.getMessage(), null); + } + } + + private void stopVideoRecording(@NonNull final Result result) { + if (!recordingVideo) { + result.success(null); + return; + } + + try { + recordingVideo = false; + mediaRecorder.stop(); + mediaRecorder.reset(); + startPreview(); + result.success(null); + } catch (CameraAccessException | IllegalStateException e) { + result.error("videoRecordingFailed", e.getMessage(), null); + } + } + + private void startPreview() throws CameraAccessException { + closeCaptureSession(); + + SurfaceTexture surfaceTexture = textureEntry.surfaceTexture(); + surfaceTexture.setDefaultBufferSize(previewSize.getWidth(), previewSize.getHeight()); + captureRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW); + + List surfaces = new ArrayList<>(); + + Surface previewSurface = new Surface(surfaceTexture); + surfaces.add(previewSurface); + captureRequestBuilder.addTarget(previewSurface); + + surfaces.add(imageReader.getSurface()); + + cameraDevice.createCaptureSession( + surfaces, + new CameraCaptureSession.StateCallback() { + + @Override + public void onConfigured(@NonNull CameraCaptureSession session) { + if (cameraDevice == null) { + sendErrorEvent("The camera was closed during configuration."); + return; + } + try { + cameraCaptureSession = session; + captureRequestBuilder.set( + CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO); + cameraCaptureSession.setRepeatingRequest(captureRequestBuilder.build(), null, null); + } catch (CameraAccessException e) { + sendErrorEvent(e.getMessage()); + } + } + + @Override + public void onConfigureFailed(@NonNull CameraCaptureSession cameraCaptureSession) { + sendErrorEvent("Failed to configure the camera for preview."); + } + }, + null); + } + + private void sendErrorEvent(String errorDescription) { + if (eventSink != null) { Map event = new HashMap<>(); event.put("eventType", "error"); - event.put("errorDescription", "Unable to pause camera"); + event.put("errorDescription", errorDescription); eventSink.success(event); } } - long getTextureId() { - return textureEntry.id(); - } - - void dispose() { + private void closeCaptureSession() { if (cameraCaptureSession != null) { cameraCaptureSession.close(); cameraCaptureSession = null; } + } + + private void close() { + closeCaptureSession(); + if (cameraDevice != null) { cameraDevice.close(); cameraDevice = null; } + if (imageReader != null) { + imageReader.close(); + imageReader = null; + } + if (mediaRecorder != null) { + mediaRecorder.reset(); + mediaRecorder.release(); + mediaRecorder = null; + } + } + + private void dispose() { + close(); textureEntry.release(); } } diff --git a/packages/camera/example/android/app/build.gradle b/packages/camera/example/android/app/build.gradle index 0f85a6381c40..a32d31c26daa 100644 --- a/packages/camera/example/android/app/build.gradle +++ b/packages/camera/example/android/app/build.gradle @@ -23,6 +23,7 @@ android { defaultConfig { minSdkVersion 21 + targetSdkVersion 27 applicationId "io.flutter.plugins.cameraexample" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } diff --git a/packages/camera/example/android/app/src/main/AndroidManifest.xml b/packages/camera/example/android/app/src/main/AndroidManifest.xml index c056dca0de35..15f6087e4ebe 100644 --- a/packages/camera/example/android/app/src/main/AndroidManifest.xml +++ b/packages/camera/example/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,10 @@ + + - LSApplicationCategoryType - CFBundleDevelopmentRegion en CFBundleExecutable @@ -22,8 +20,14 @@ ???? CFBundleVersion 1 + LSApplicationCategoryType + LSRequiresIPhoneOS + NSCameraUsageDescription + Can I use the camera please? Only for demo purpose of the app + NSMicrophoneUsageDescription + Only for demo purpose of the app UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -45,8 +49,6 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - NSCameraUsageDescription - Can I use the camera please? UIViewControllerBasedStatusBarAppearance diff --git a/packages/camera/example/lib/main.dart b/packages/camera/example/lib/main.dart index f95daef0491d..e427892b0185 100644 --- a/packages/camera/example/lib/main.dart +++ b/packages/camera/example/lib/main.dart @@ -1,9 +1,10 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/material.dart'; import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:video_player/video_player.dart'; class CameraExampleHome extends StatefulWidget { @override @@ -12,7 +13,8 @@ class CameraExampleHome extends StatefulWidget { } } -IconData cameraLensIcon(CameraLensDirection direction) { +/// Returns a suitable camera icon for [direction]. +IconData getCameraLensIcon(CameraLensDirection direction) { switch (direction) { case CameraLensDirection.back: return Icons.camera_rear; @@ -24,149 +26,344 @@ IconData cameraLensIcon(CameraLensDirection direction) { throw new ArgumentError('Unknown lens direction'); } +void logError(String code, String message) => + print('Error: $code\nError Message: $message'); + class _CameraExampleHomeState extends State { - bool opening = false; CameraController controller; String imagePath; - int pictureCount = 0; + String videoPath; + VideoPlayerController videoController; + VoidCallback videoPlayerListener; - @override - void initState() { - super.initState(); - } + final GlobalKey _scaffoldKey = new GlobalKey(); @override Widget build(BuildContext context) { - final List headerChildren = []; + return new Scaffold( + key: _scaffoldKey, + appBar: new AppBar( + title: const Text('Camera example'), + ), + body: new Column( + children: [ + new Expanded( + child: new Container( + child: new Padding( + padding: const EdgeInsets.all(1.0), + child: new Center( + child: _cameraPreviewWidget(), + ), + ), + decoration: new BoxDecoration( + color: Colors.black, + border: new Border.all( + color: controller != null && controller.value.isRecordingVideo + ? Colors.redAccent + : Colors.grey, + width: 3.0, + ), + ), + ), + ), + _captureControlRowWidget(), + new Padding( + padding: const EdgeInsets.all(5.0), + child: new Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + _cameraTogglesRowWidget(), + _thumbnailWidget(), + ], + ), + ), + ], + ), + ); + } + + /// Display the preview from the camera (or a message if the preview is not available). + Widget _cameraPreviewWidget() { + if (controller == null || !controller.value.isInitialized) { + return const Text( + 'Tap a camera', + style: const TextStyle( + color: Colors.white, + fontSize: 24.0, + fontWeight: FontWeight.w900, + ), + ); + } else { + return new AspectRatio( + aspectRatio: controller.value.aspectRatio, + child: new CameraPreview(controller), + ); + } + } - final List cameraList = []; + /// Display the thumbnail of the captured image or video. + Widget _thumbnailWidget() { + return new Expanded( + child: new Align( + alignment: Alignment.centerRight, + child: videoController == null && imagePath == null + ? null + : new SizedBox( + child: (videoController == null) + ? new Image.file(new File(imagePath)) + : new Container( + child: new Center( + child: new AspectRatio( + aspectRatio: videoController.value.size != null + ? videoController.value.aspectRatio + : 1.0, + child: new VideoPlayer(videoController)), + ), + decoration: new BoxDecoration( + border: new Border.all(color: Colors.pink)), + ), + width: 64.0, + height: 64.0, + ), + ), + ); + } + + /// Display the control bar with buttons to take pictures and record videos. + Widget _captureControlRowWidget() { + return new Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.max, + children: [ + new IconButton( + icon: const Icon(Icons.camera_alt), + color: Colors.blue, + onPressed: controller != null && + controller.value.isInitialized && + !controller.value.isRecordingVideo + ? onTakePictureButtonPressed + : null, + ), + new IconButton( + icon: const Icon(Icons.videocam), + color: Colors.blue, + onPressed: controller != null && + controller.value.isInitialized && + !controller.value.isRecordingVideo + ? onVideoRecordButtonPressed + : null, + ), + new IconButton( + icon: const Icon(Icons.stop), + color: Colors.red, + onPressed: controller != null && + controller.value.isInitialized && + controller.value.isRecordingVideo + ? onStopButtonPressed + : null, + ) + ], + ); + } + + /// Display a row of toggle to select the camera (or a message if no camera is available). + Widget _cameraTogglesRowWidget() { + final List toggles = []; if (cameras.isEmpty) { - cameraList.add(const Text('No cameras found')); + return const Text('No camera found'); } else { for (CameraDescription cameraDescription in cameras) { - cameraList.add( + toggles.add( new SizedBox( width: 90.0, child: new RadioListTile( - title: new Icon(cameraLensIcon(cameraDescription.lensDirection)), + title: + new Icon(getCameraLensIcon(cameraDescription.lensDirection)), groupValue: controller?.description, value: cameraDescription, - onChanged: (CameraDescription newValue) async { - final CameraController tempController = controller; - controller = null; - await tempController?.dispose(); - controller = - new CameraController(newValue, ResolutionPreset.high); - await controller.initialize(); - setState(() {}); - }, + onChanged: controller != null && controller.value.isRecordingVideo + ? null + : onNewCameraSelected, ), ), ); } } - headerChildren.add(new Column(children: cameraList)); + return new Row(children: toggles); + } + + String timestamp() => new DateTime.now().millisecondsSinceEpoch.toString(); + + void showInSnackBar(String message) { + _scaffoldKey.currentState + .showSnackBar(new SnackBar(content: new Text(message))); + } + + void onNewCameraSelected(CameraDescription cameraDescription) async { if (controller != null) { - headerChildren.add(playPauseButton()); + await controller.dispose(); } - if (imagePath != null) { - headerChildren.add(imageWidget()); + controller = new CameraController(cameraDescription, ResolutionPreset.high); + + // If the controller is updated then update the UI. + controller.addListener(() { + if (mounted) setState(() {}); + if (controller.value.hasError) { + showInSnackBar('Camera error ${controller.value.errorDescription}'); + } + }); + + try { + await controller.initialize(); + } on CameraException catch (e) { + _showCameraException(e); } - final List columnChildren = []; - columnChildren.add(new Row(children: headerChildren)); - if (controller == null || !controller.value.initialized) { - columnChildren.add(const Text('Tap a camera')); - } else if (controller.value.hasError) { - columnChildren.add( - new Text('Camera error ${controller.value.errorDescription}'), - ); - } else { - columnChildren.add( - new Expanded( - child: new Padding( - padding: const EdgeInsets.all(5.0), - child: new Center( - child: new AspectRatio( - aspectRatio: controller.value.aspectRatio, - child: new CameraPreview(controller), - ), - ), - ), - ), - ); + if (mounted) { + setState(() {}); } - return new Scaffold( - appBar: new AppBar( - title: const Text('Camera example'), - ), - body: new Column(children: columnChildren), - floatingActionButton: (controller == null) - ? null - : new FloatingActionButton( - child: const Icon(Icons.camera), - onPressed: controller.value.isStarted ? capture : null, - ), - ); } - Widget imageWidget() { - return new Expanded( - child: new Align( - alignment: Alignment.centerRight, - child: new SizedBox( - child: new Image.file(new File(imagePath)), - width: 64.0, - height: 64.0, - ), - ), - ); + void onTakePictureButtonPressed() { + takePicture().then((String filePath) { + if (mounted) { + setState(() { + imagePath = filePath; + videoController?.dispose(); + videoController = null; + }); + if (filePath != null) showInSnackBar('Picture saved to $filePath'); + } + }); } - Widget playPauseButton() { - return new FlatButton( - onPressed: () { - setState( - () { - if (controller.value.isStarted) { - controller.stop(); - } else { - controller.start(); - } - }, - ); - }, - child: - new Icon(controller.value.isStarted ? Icons.pause : Icons.play_arrow), - ); + void onVideoRecordButtonPressed() { + startVideoRecording().then((String filePath) { + if (mounted) setState(() {}); + if (filePath != null) showInSnackBar('Saving video to $filePath'); + }); } - Future capture() async { - if (controller.value.isStarted) { - final Directory tempDir = await getTemporaryDirectory(); - if (!mounted) { - return; - } - final String tempPath = tempDir.path; - final String path = '$tempPath/picture${pictureCount++}.jpg'; - await controller.capture(path); - if (!mounted) { - return; + void onStopButtonPressed() { + stopVideoRecording().then((_) { + if (mounted) setState(() {}); + showInSnackBar('Video recorded to: $videoPath'); + }); + } + + Future startVideoRecording() async { + if (!controller.value.isInitialized) { + showInSnackBar('Error: select a camera first.'); + return null; + } + + final Directory extDir = await getApplicationDocumentsDirectory(); + final String dirPath = '${extDir.path}/Movies/flutter_test'; + await new Directory(dirPath).create(recursive: true); + final String filePath = '$dirPath/${timestamp()}.mp4'; + + if (controller.value.isRecordingVideo) { + // A recording is already started, do nothing. + return null; + } + + try { + videoPath = filePath; + await controller.startVideoRecording(filePath); + } on CameraException catch (e) { + _showCameraException(e); + return null; + } + return filePath; + } + + Future stopVideoRecording() async { + if (!controller.value.isRecordingVideo) { + return null; + } + + try { + await controller.stopVideoRecording(); + } on CameraException catch (e) { + _showCameraException(e); + return null; + } + + await _startVideoPlayer(); + } + + Future _startVideoPlayer() async { + final VideoPlayerController vcontroller = + new VideoPlayerController.file(new File(videoPath)); + videoPlayerListener = () { + if (videoController != null && videoController.value.size != null) { + // Refreshing the state to update video player with the correct ratio. + if (mounted) setState(() {}); + videoController.removeListener(videoPlayerListener); } - setState( - () { - imagePath = path; - }, - ); + }; + vcontroller.addListener(videoPlayerListener); + await vcontroller.setLooping(true); + await vcontroller.initialize(); + await videoController?.dispose(); + if (mounted) { + setState(() { + imagePath = null; + videoController = vcontroller; + }); + } + await vcontroller.play(); + } + + Future takePicture() async { + if (!controller.value.isInitialized) { + showInSnackBar('Error: select a camera first.'); + return null; } + final Directory extDir = await getApplicationDocumentsDirectory(); + final String dirPath = '${extDir.path}/Pictures/flutter_test'; + await new Directory(dirPath).create(recursive: true); + final String filePath = '$dirPath/${timestamp()}.jpg'; + + if (controller.value.isTakingPicture) { + // A capture is already pending, do nothing. + return null; + } + + try { + await controller.takePicture(filePath); + } on CameraException catch (e) { + _showCameraException(e); + return null; + } + return filePath; + } + + void _showCameraException(CameraException e) { + logError(e.code, e.description); + showInSnackBar('Error: ${e.code}\n${e.description}'); + } +} + +class CameraApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return new MaterialApp( + home: new CameraExampleHome(), + ); } } List cameras; Future main() async { - cameras = await availableCameras(); - runApp(new MaterialApp(home: new CameraExampleHome())); + // Fetch the available cameras before initializing the app. + try { + cameras = await availableCameras(); + } on CameraException catch (e) { + logError(e.code, e.description); + } + runApp(new CameraApp()); } diff --git a/packages/camera/example/pubspec.yaml b/packages/camera/example/pubspec.yaml index e4f4c97e44db..29402075f1fa 100644 --- a/packages/camera/example/pubspec.yaml +++ b/packages/camera/example/pubspec.yaml @@ -7,6 +7,7 @@ dependencies: sdk: flutter camera: path: ../ + video_player: "0.5.2" dev_dependencies: flutter_test: diff --git a/packages/camera/ios/Classes/CameraPlugin.m b/packages/camera/ios/Classes/CameraPlugin.m index 4b2329a79fe4..68d5cf2830b1 100644 --- a/packages/camera/ios/Classes/CameraPlugin.m +++ b/packages/camera/ios/Classes/CameraPlugin.m @@ -59,8 +59,8 @@ - (void)captureOutput:(AVCapturePhotoOutput *)output } @end -@interface FLTCam - : NSObject +@interface FLTCam : NSObject @property(readonly, nonatomic) int64_t textureId; @property(nonatomic, copy) void (^onFrameAvailable)(); @property(nonatomic) FlutterEventChannel *eventChannel; @@ -68,15 +68,26 @@ @interface FLTCam @property(readonly, nonatomic) AVCaptureSession *captureSession; @property(readonly, nonatomic) AVCaptureDevice *captureDevice; @property(readonly, nonatomic) AVCapturePhotoOutput *capturePhotoOutput; +@property(readonly, nonatomic) AVCaptureVideoDataOutput *captureVideoOutput; +@property(readonly, nonatomic) AVCaptureInput *captureVideoInput; @property(readonly) CVPixelBufferRef volatile latestPixelBuffer; @property(readonly, nonatomic) CGSize previewSize; @property(readonly, nonatomic) CGSize captureSize; - +@property(strong, nonatomic) AVAssetWriter *videoWriter; +@property(strong, nonatomic) AVAssetWriterInput *videoWriterInput; +@property(strong, nonatomic) AVAssetWriterInput *audioWriterInput; +@property(strong, nonatomic) AVAssetWriterInputPixelBufferAdaptor *assetWriterPixelBufferAdaptor; +@property(strong, nonatomic) AVCaptureVideoDataOutput *videoOutput; +@property(strong, nonatomic) AVCaptureAudioDataOutput *audioOutput; +@property(assign, nonatomic) BOOL isRecording; +@property(assign, nonatomic) BOOL isAudioSetup; - (instancetype)initWithCameraName:(NSString *)cameraName resolutionPreset:(NSString *)resolutionPreset error:(NSError **)error; - (void)start; - (void)stop; +- (void)startVideoRecordingAtPath:(NSString *)path result:(FlutterResult)result; +- (void)stopVideoRecordingWithResult:(FlutterResult)result; - (void)captureToFile:(NSString *)filename result:(FlutterResult)result; @end @@ -100,7 +111,7 @@ - (instancetype)initWithCameraName:(NSString *)cameraName _captureSession.sessionPreset = preset; _captureDevice = [AVCaptureDevice deviceWithUniqueID:cameraName]; NSError *localError = nil; - AVCaptureInput *input = + _captureVideoInput = [AVCaptureDeviceInput deviceInputWithDevice:_captureDevice error:&localError]; if (localError) { *error = localError; @@ -110,20 +121,21 @@ - (instancetype)initWithCameraName:(NSString *)cameraName CMVideoFormatDescriptionGetDimensions([[_captureDevice activeFormat] formatDescription]); _previewSize = CGSizeMake(dimensions.width, dimensions.height); - AVCaptureVideoDataOutput *output = [AVCaptureVideoDataOutput new]; - output.videoSettings = + _captureVideoOutput = [AVCaptureVideoDataOutput new]; + _captureVideoOutput.videoSettings = @{(NSString *)kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_32BGRA) }; - [output setAlwaysDiscardsLateVideoFrames:YES]; - [output setSampleBufferDelegate:self queue:dispatch_get_main_queue()]; + [_captureVideoOutput setAlwaysDiscardsLateVideoFrames:YES]; + [_captureVideoOutput setSampleBufferDelegate:self queue:dispatch_get_main_queue()]; AVCaptureConnection *connection = - [AVCaptureConnection connectionWithInputPorts:input.ports output:output]; + [AVCaptureConnection connectionWithInputPorts:_captureVideoInput.ports + output:_captureVideoOutput]; if ([_captureDevice position] == AVCaptureDevicePositionFront) { connection.videoMirrored = YES; } connection.videoOrientation = AVCaptureVideoOrientationPortrait; - [_captureSession addInputWithNoConnections:input]; - [_captureSession addOutputWithNoConnections:output]; + [_captureSession addInputWithNoConnections:_captureVideoInput]; + [_captureSession addOutputWithNoConnections:_captureVideoOutput]; [_captureSession addConnection:connection]; _capturePhotoOutput = [AVCapturePhotoOutput new]; [_captureSession addOutput:_capturePhotoOutput]; @@ -148,17 +160,87 @@ - (void)captureToFile:(NSString *)path result:(FlutterResult)result { - (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection { - CVPixelBufferRef newBuffer = CMSampleBufferGetImageBuffer(sampleBuffer); - CFRetain(newBuffer); - CVPixelBufferRef old = _latestPixelBuffer; - while (!OSAtomicCompareAndSwapPtrBarrier(old, newBuffer, (void **)&_latestPixelBuffer)) { - old = _latestPixelBuffer; + if (output == _captureVideoOutput) { + CVPixelBufferRef newBuffer = CMSampleBufferGetImageBuffer(sampleBuffer); + CFRetain(newBuffer); + CVPixelBufferRef old = _latestPixelBuffer; + while (!OSAtomicCompareAndSwapPtrBarrier(old, newBuffer, (void **)&_latestPixelBuffer)) { + old = _latestPixelBuffer; + } + if (old != nil) { + CFRelease(old); + } + if (_onFrameAvailable) { + _onFrameAvailable(); + } + } + if (!CMSampleBufferDataIsReady(sampleBuffer)) { + _eventSink(@{ + @"event" : @"error", + @"errorDescription" : @"sample buffer is not ready. Skipping sample" + }); + return; + } + if (_isRecording) { + if (_videoWriter.status == AVAssetWriterStatusFailed) { + _eventSink(@{ + @"event" : @"error", + @"errorDescription" : [NSString stringWithFormat:@"%@", _videoWriter.error] + }); + return; + } + CMTime lastSampleTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer); + if (_videoWriter.status != AVAssetWriterStatusWriting) { + [_videoWriter startWriting]; + [_videoWriter startSessionAtSourceTime:lastSampleTime]; + } + if (output == _captureVideoOutput) { + [self newVideoSample:sampleBuffer]; + } else { + [self newAudioSample:sampleBuffer]; + } + } +} + +- (void)newVideoSample:(CMSampleBufferRef)sampleBuffer { + if (_videoWriter.status != AVAssetWriterStatusWriting) { + if (_videoWriter.status == AVAssetWriterStatusFailed) { + _eventSink(@{ + @"event" : @"error", + @"errorDescription" : [NSString stringWithFormat:@"%@", _videoWriter.error] + }); + } + return; + } + if (_videoWriterInput.readyForMoreMediaData) { + if (![_videoWriterInput appendSampleBuffer:sampleBuffer]) { + _eventSink(@{ + @"event" : @"error", + @"errorDescription" : + [NSString stringWithFormat:@"%@", @"Unable to write to video input"] + }); + } } - if (old != nil) { - CFRelease(old); +} + +- (void)newAudioSample:(CMSampleBufferRef)sampleBuffer { + if (_videoWriter.status != AVAssetWriterStatusWriting) { + if (_videoWriter.status == AVAssetWriterStatusFailed) { + _eventSink(@{ + @"event" : @"error", + @"errorDescription" : [NSString stringWithFormat:@"%@", _videoWriter.error] + }); + } + return; } - if (_onFrameAvailable) { - _onFrameAvailable(); + if (_audioWriterInput.readyForMoreMediaData) { + if (![_audioWriterInput appendSampleBuffer:sampleBuffer]) { + _eventSink(@{ + @"event" : @"error", + @"errorDescription" : + [NSString stringWithFormat:@"%@", @"Unable to write to audio input"] + }); + } } } @@ -196,12 +278,130 @@ - (FlutterError *_Nullable)onListenWithArguments:(id _Nullable)arguments _eventSink = events; return nil; } +- (void)startVideoRecordingAtPath:(NSString *)path result:(FlutterResult)result { + if (!_isRecording) { + if (![self setupWriterForPath:path]) { + _eventSink(@{@"event" : @"error", @"errorDescription" : @"Setup Writer Failed"}); + return; + } + [_captureSession stopRunning]; + _isRecording = YES; + [_captureSession startRunning]; + result(nil); + } else { + _eventSink(@{@"event" : @"error", @"errorDescription" : @"Video is already recording!"}); + } +} + +- (void)stopVideoRecordingWithResult:(FlutterResult)result { + if (_isRecording) { + _isRecording = NO; + if (_videoWriter.status != AVAssetWriterStatusUnknown) { + [_videoWriter finishWritingWithCompletionHandler:^{ + if (self->_videoWriter.status == AVAssetWriterStatusCompleted) { + result(nil); + } else { + self->_eventSink(@{ + @"event" : @"error", + @"errorDescription" : @"AVAssetWriter could not finish writing!" + }); + } + }]; + } + } else { + NSError *error = + [NSError errorWithDomain:NSCocoaErrorDomain + code:NSURLErrorResourceUnavailable + userInfo:@{NSLocalizedDescriptionKey : @"Video is not recording!"}]; + result([error flutterError]); + } +} + +- (BOOL)setupWriterForPath:(NSString *)path { + NSError *error = nil; + NSURL *outputURL; + if (path != nil) { + outputURL = [NSURL fileURLWithPath:path]; + } else { + return NO; + } + if (!_isAudioSetup) { + [self setUpCaptureSessionForAudio]; + } + _videoWriter = + [[AVAssetWriter alloc] initWithURL:outputURL fileType:AVFileTypeQuickTimeMovie error:&error]; + NSParameterAssert(_videoWriter); + if (error) { + _eventSink(@{@"event" : @"error", @"errorDescription" : error.description}); + return NO; + } + NSDictionary *videoSettings = [NSDictionary + dictionaryWithObjectsAndKeys:AVVideoCodecH264, AVVideoCodecKey, + [NSNumber numberWithInt:_previewSize.height], AVVideoWidthKey, + [NSNumber numberWithInt:_previewSize.width], AVVideoHeightKey, + nil]; + _videoWriterInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo + outputSettings:videoSettings]; + NSParameterAssert(_videoWriterInput); + _videoWriterInput.expectsMediaDataInRealTime = YES; + + // Add the audio input + AudioChannelLayout acl; + bzero(&acl, sizeof(acl)); + acl.mChannelLayoutTag = kAudioChannelLayoutTag_Mono; + NSDictionary *audioOutputSettings = nil; + // Both type of audio inputs causes output video file to be corrupted. + audioOutputSettings = [NSDictionary + dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:kAudioFormatMPEG4AAC], AVFormatIDKey, + [NSNumber numberWithFloat:44100.0], AVSampleRateKey, + [NSNumber numberWithInt:1], AVNumberOfChannelsKey, + [NSData dataWithBytes:&acl length:sizeof(acl)], + AVChannelLayoutKey, nil]; + _audioWriterInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeAudio + outputSettings:audioOutputSettings]; + _audioWriterInput.expectsMediaDataInRealTime = YES; + [_videoWriter addInput:_videoWriterInput]; + [_videoWriter addInput:_audioWriterInput]; + dispatch_queue_t queue = dispatch_queue_create("MyQueue", NULL); + [_captureVideoOutput setSampleBufferDelegate:self queue:queue]; + [_audioOutput setSampleBufferDelegate:self queue:queue]; + + return YES; +} +- (void)setUpCaptureSessionForAudio { + NSError *error = nil; + // Create a device input with the device and add it to the session. + // Setup the audio input. + AVCaptureDevice *audioDevice = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeAudio]; + AVCaptureDeviceInput *audioInput = + [AVCaptureDeviceInput deviceInputWithDevice:audioDevice error:&error]; + if (error) { + _eventSink(@{@"event" : @"error", @"errorDescription" : error.description}); + } + // Setup the audio output. + _audioOutput = [[AVCaptureAudioDataOutput alloc] init]; + + if ([_captureSession canAddInput:audioInput]) { + [_captureSession addInput:audioInput]; + + if ([_captureSession canAddOutput:_audioOutput]) { + [_captureSession addOutput:_audioOutput]; + _isAudioSetup = YES; + } else { + _eventSink(@{ + @"event" : @"error", + @"errorDescription" : @"Unable to add Audio input/output to session capture" + }); + _isAudioSetup = NO; + } + } +} @end @interface CameraPlugin () @property(readonly, nonatomic) NSObject *registry; @property(readonly, nonatomic) NSObject *messenger; -@property(readonly, nonatomic) NSMutableDictionary *cams; +@property(readonly, nonatomic) FLTCam *camera; @end @implementation CameraPlugin @@ -220,19 +420,16 @@ - (instancetype)initWithRegistry:(NSObject *)registry NSAssert(self, @"super init cannot be nil"); _registry = registry; _messenger = messenger; - _cams = [NSMutableDictionary dictionaryWithCapacity:1]; return self; } - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { if ([@"init" isEqualToString:call.method]) { - for (NSNumber *textureId in _cams) { - [_registry unregisterTexture:[textureId longLongValue]]; - [[_cams objectForKey:textureId] close]; + if (_camera) { + [_camera close]; } - [_cams removeAllObjects]; result(nil); - } else if ([@"list" isEqualToString:call.method]) { + } else if ([@"availableCameras" isEqualToString:call.method]) { AVCaptureDeviceDiscoverySession *discoverySession = [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:@[ AVCaptureDeviceTypeBuiltInWideAngleCamera ] mediaType:AVMediaTypeVideo @@ -259,7 +456,7 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result }]; } result(reply); - } else if ([@"create" isEqualToString:call.method]) { + } else if ([@"initialize" isEqualToString:call.method]) { NSString *cameraName = call.arguments[@"cameraName"]; NSString *resolutionPreset = call.arguments[@"resolutionPreset"]; NSError *error; @@ -269,8 +466,11 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result if (error) { result([error flutterError]); } else { + if (_camera) { + [_camera close]; + } int64_t textureId = [_registry registerTexture:cam]; - _cams[@(textureId)] = cam; + _camera = cam; cam.onFrameAvailable = ^{ [_registry textureFrameAvailable:textureId]; }; @@ -288,24 +488,22 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result @"captureWidth" : @(cam.captureSize.width), @"captureHeight" : @(cam.captureSize.height), }); + [cam start]; } } else { NSDictionary *argsMap = call.arguments; NSUInteger textureId = ((NSNumber *)argsMap[@"textureId"]).unsignedIntegerValue; - FLTCam *cam = _cams[@(textureId)]; - if ([@"start" isEqualToString:call.method]) { - [cam start]; - result(nil); - } else if ([@"stop" isEqualToString:call.method]) { - [cam stop]; - result(nil); - } else if ([@"capture" isEqualToString:call.method]) { - [cam captureToFile:call.arguments[@"path"] result:result]; + + if ([@"takePicture" isEqualToString:call.method]) { + [_camera captureToFile:call.arguments[@"path"] result:result]; } else if ([@"dispose" isEqualToString:call.method]) { [_registry unregisterTexture:textureId]; - [cam close]; - [_cams removeObjectForKey:@(textureId)]; + [_camera close]; result(nil); + } else if ([@"startVideoRecording" isEqualToString:call.method]) { + [_camera startVideoRecordingAtPath:call.arguments[@"filePath"] result:result]; + } else if ([@"stopVideoRecording" isEqualToString:call.method]) { + [_camera stopVideoRecordingWithResult:result]; } else { result(FlutterMethodNotImplemented); } diff --git a/packages/camera/lib/camera.dart b/packages/camera/lib/camera.dart index 7648f76a4b6d..837e02bf12cc 100644 --- a/packages/camera/lib/camera.dart +++ b/packages/camera/lib/camera.dart @@ -1,7 +1,7 @@ import 'dart:async'; -import 'package:flutter/widgets.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; final MethodChannel _channel = const MethodChannel('plugins.flutter.io/camera') ..invokeMethod('init'); @@ -10,6 +10,7 @@ enum CameraLensDirection { front, back, external } enum ResolutionPreset { low, medium, high } +/// Returns the resolution preset as a String. String serializeResolutionPreset(ResolutionPreset resolutionPreset) { switch (resolutionPreset) { case ResolutionPreset.high: @@ -39,7 +40,8 @@ CameraLensDirection _parseCameraLensDirection(String string) { /// May throw a [CameraException]. Future> availableCameras() async { try { - final List cameras = await _channel.invokeMethod('list'); + final List cameras = + await _channel.invokeMethod('availableCameras'); return cameras.map((dynamic camera) { return new CameraDescription( name: camera['name'], @@ -54,6 +56,7 @@ Future> availableCameras() async { class CameraDescription { final String name; final CameraLensDirection lensDirection; + CameraDescription({this.name, this.lensDirection}); @override @@ -74,75 +77,92 @@ class CameraDescription { } } +/// This is thrown when the plugin reports an error. class CameraException implements Exception { String code; String description; + CameraException(this.code, this.description); @override String toString() => '$runtimeType($code, $description)'; } +// Build the UI texture view of the video data with textureId. class CameraPreview extends StatelessWidget { final CameraController controller; + const CameraPreview(this.controller); @override Widget build(BuildContext context) { - return controller.value.initialized + return controller.value.isInitialized ? new Texture(textureId: controller._textureId) : new Container(); } } +/// The state of a [CameraController]. class CameraValue { - /// True if the camera is on. - final bool isStarted; - /// True after [CameraController.initialize] has completed successfully. - final bool initialized; + final bool isInitialized; + + /// True when a picture capture request has been sent but as not yet returned. + final bool isTakingPicture; + + /// True when the camera is recording (not the same as previewing). + final bool isRecordingVideo; final String errorDescription; /// The size of the preview in pixels. /// - /// Is `null` until initialized is `true`. + /// Is `null` until [isInitialized] is `true`. final Size previewSize; - const CameraValue( - {this.isStarted, - this.initialized, - this.errorDescription, - this.previewSize}); + const CameraValue({ + this.isInitialized, + this.errorDescription, + this.previewSize, + this.isRecordingVideo, + this.isTakingPicture, + }); - const CameraValue.uninitialized() : this(isStarted: true, initialized: false); + const CameraValue.uninitialized() + : this( + isInitialized: false, + isRecordingVideo: false, + isTakingPicture: false); /// Convenience getter for `previewSize.height / previewSize.width`. /// - /// Can only be called when [initialized] is done. + /// Can only be called when [initialize] is done. double get aspectRatio => previewSize.height / previewSize.width; bool get hasError => errorDescription != null; CameraValue copyWith({ - bool isStarted, - bool initialized, + bool isInitialized, + bool isRecordingVideo, + bool isTakingPicture, String errorDescription, Size previewSize, }) { return new CameraValue( - isStarted: isStarted ?? this.isStarted, - initialized: initialized ?? this.initialized, - errorDescription: errorDescription ?? this.errorDescription, + isInitialized: isInitialized ?? this.isInitialized, + errorDescription: errorDescription, previewSize: previewSize ?? this.previewSize, + isRecordingVideo: isRecordingVideo ?? this.isRecordingVideo, + isTakingPicture: isTakingPicture ?? this.isTakingPicture, ); } @override String toString() { return '$runtimeType(' - 'started: $isStarted, ' - 'initialized: $initialized, ' + 'isRecordingVideo: $isRecordingVideo, ' + 'isRecordingVideo: $isRecordingVideo, ' + 'isInitialized: $isInitialized, ' 'errorDescription: $errorDescription, ' 'previewSize: $previewSize)'; } @@ -158,8 +178,9 @@ class CameraValue { class CameraController extends ValueNotifier { final CameraDescription description; final ResolutionPreset resolutionPreset; + int _textureId; - bool _disposed = false; + bool _isDisposed = false; StreamSubscription _eventSubscription; Completer _creatingCompleter; @@ -170,13 +191,13 @@ class CameraController extends ValueNotifier { /// /// Throws a [CameraException] if the initialization fails. Future initialize() async { - if (_disposed) { - return; + if (_isDisposed) { + return new Future.value(null); } try { _creatingCompleter = new Completer(); final Map reply = await _channel.invokeMethod( - 'create', + 'initialize', { 'cameraName': description.name, 'resolutionPreset': serializeResolutionPreset(resolutionPreset), @@ -184,15 +205,13 @@ class CameraController extends ValueNotifier { ); _textureId = reply['textureId']; value = value.copyWith( - initialized: true, + isInitialized: true, previewSize: new Size( reply['previewWidth'].toDouble(), reply['previewHeight'].toDouble(), ), ); - _applyStartStop(); } on PlatformException catch (e) { - value = value.copyWith(errorDescription: e.message); throw new CameraException(e.code, e.message); } _eventSubscription = @@ -200,15 +219,25 @@ class CameraController extends ValueNotifier { .receiveBroadcastStream() .listen(_listener); _creatingCompleter.complete(null); + return _creatingCompleter.future; } + /// Listen to events from the native plugins. + /// + /// A "cameraClosing" event is sent when the camera is closed automatically by the system (for example when the app go to background). The plugin will try to reopen the camera automatically but any ongoing recording will end. void _listener(dynamic event) { final Map map = event; - if (_disposed) { + if (_isDisposed) { return; } - if (map['eventType'] == 'error') { - value = value.copyWith(errorDescription: event['errorDescription']); + + switch (map['eventType']) { + case 'error': + value = value.copyWith(errorDescription: event['errorDescription']); + break; + case 'cameraClosing': + value = value.copyWith(isRecordingVideo: false); + break; } } @@ -217,75 +246,112 @@ class CameraController extends ValueNotifier { /// A path can for example be obtained using /// [path_provider](https://pub.dartlang.org/packages/path_provider). /// + /// If a file already exists at the provided path an error will be thrown. + /// The file can be read as this function returns. + /// /// Throws a [CameraException] if the capture fails. - Future capture(String path) async { - if (!value.initialized || _disposed) { + Future takePicture(String path) async { + if (!value.isInitialized || _isDisposed) { throw new CameraException( - 'Uninitialized capture()', - 'capture() was called on uninitialized CameraController', + 'Uninitialized CameraController.', + 'takePicture was called on uninitialized CameraController', + ); + } + if (value.isTakingPicture) { + throw new CameraException( + 'Previous capture has not returned yet.', + 'takePicture was called before the previous capture returned.', ); } try { + value = value.copyWith(isTakingPicture: true); await _channel.invokeMethod( - 'capture', + 'takePicture', {'textureId': _textureId, 'path': path}, ); + value = value.copyWith(isTakingPicture: false); } on PlatformException catch (e) { + value = value.copyWith(isTakingPicture: false); throw new CameraException(e.code, e.message); } } - void _applyStartStop() { - if (value.initialized && !_disposed) { - if (value.isStarted) { - _channel.invokeMethod( - 'start', - {'textureId': _textureId}, - ); - } else { - _channel.invokeMethod( - 'stop', - {'textureId': _textureId}, - ); - } - } - } - - /// Starts the preview. + /// Start a video recording and save the file to [path]. + /// + /// A path can for example be obtained using + /// [path_provider](https://pub.dartlang.org/packages/path_provider). + /// + /// The file is written on the flight as the video is being recorded. + /// If a file already exists at the provided path an error will be thrown. + /// The file can be read as soon as [stopVideoRecording] returns. /// - /// If called before [initialize] it will take effect just after - /// initialization is done. - void start() { - value = value.copyWith(isStarted: true); - _applyStartStop(); + /// Throws a [CameraException] if the capture fails. + Future startVideoRecording(String filePath) async { + if (!value.isInitialized || _isDisposed) { + throw new CameraException( + 'Uninitialized CameraController', + 'startVideoRecording was called on uninitialized CameraController', + ); + } + if (value.isRecordingVideo) { + throw new CameraException( + 'A video recording is already started.', + 'startVideoRecording was called when a recording is already started.', + ); + } + try { + await _channel.invokeMethod( + 'startVideoRecording', + {'textureId': _textureId, 'filePath': filePath}, + ); + value = value.copyWith(isRecordingVideo: true); + } on PlatformException catch (e) { + throw new CameraException(e.code, e.message); + } } - /// Stops the preview. - /// - /// If called before [initialize] it will take effect just after - /// initialization is done. - void stop() { - value = value.copyWith(isStarted: false); - _applyStartStop(); + /// Stop recording. + Future stopVideoRecording() async { + if (!value.isInitialized || _isDisposed) { + throw new CameraException( + 'Uninitialized CameraController', + 'stopVideoRecording was called on uninitialized CameraController', + ); + } + if (!value.isRecordingVideo) { + throw new CameraException( + 'No video is recording', + 'stopVideoRecording was called when no video is recording.', + ); + } + try { + value = value.copyWith(isRecordingVideo: false); + await _channel.invokeMethod( + 'stopVideoRecording', + {'textureId': _textureId}, + ); + } on PlatformException catch (e) { + throw new CameraException(e.code, e.message); + } } /// Releases the resources of this camera. @override - Future dispose() { - if (_disposed) { + Future dispose() async { + if (_isDisposed) { return new Future.value(null); } - _disposed = true; + _isDisposed = true; super.dispose(); if (_creatingCompleter == null) { return new Future.value(null); } else { return _creatingCompleter.future.then((_) async { - await _eventSubscription?.cancel(); await _channel.invokeMethod( 'dispose', {'textureId': _textureId}, ); + await _eventSubscription?.cancel(); }); } } diff --git a/packages/camera/pubspec.yaml b/packages/camera/pubspec.yaml index 5c0038e1ef9e..0c689efbbdbd 100644 --- a/packages/camera/pubspec.yaml +++ b/packages/camera/pubspec.yaml @@ -1,8 +1,14 @@ name: camera description: A Flutter plugin for getting information about and controlling the camera on Android and iOS. Supports previewing the camera feed and capturing images. -version: 0.1.2 -author: Flutter Team +version: 0.2.0 +authors: + - Flutter Team + - Luigi Agosti + - Quentin Le Guennec + - Koushik Ravikumar + - Nissim Dsilva + homepage: https://github.com/flutter/plugins/tree/master/packages/camera dependencies: