diff --git a/packages/camera/CHANGELOG.md b/packages/camera/CHANGELOG.md index c096a9a8ad54..7e2bcbc13436 100644 --- a/packages/camera/CHANGELOG.md +++ b/packages/camera/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.6.0 + +* **Breaking Changes** +* `availableCameras()` -> `CameraController.availableCameras()` +* `enum CameraLensDirection` -> `enum LensDirection` +* `CameraController` constructor no longer takes a `ResolutionPreset`. `CameraController({CameraDescription description, ResolutionPreset preset})` -> CameraController({CameraDescription description}).g +* Removal of `ValueNotifier` from CameraController. + ## 0.5.2+1 * Fix bug that prevented video recording with audio. diff --git a/packages/camera/example/lib/main.dart b/packages/camera/example/lib/main.dart index 70b7ef285796..96f47fed4ee5 100644 --- a/packages/camera/example/lib/main.dart +++ b/packages/camera/example/lib/main.dart @@ -1,424 +1,21 @@ -import 'dart:async'; -import 'dart:io'; +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. -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 - _CameraExampleHomeState createState() { - return _CameraExampleHomeState(); - } -} - -/// Returns a suitable camera icon for [direction]. -IconData getCameraLensIcon(CameraLensDirection direction) { - switch (direction) { - case CameraLensDirection.back: - return Icons.camera_rear; - case CameraLensDirection.front: - return Icons.camera_front; - case CameraLensDirection.external: - return Icons.camera; - } - throw ArgumentError('Unknown lens direction'); +void main() { + runApp(MaterialApp(home: MyApp())); } -void logError(String code, String message) => - print('Error: $code\nError Message: $message'); - -class _CameraExampleHomeState extends State - with WidgetsBindingObserver { - CameraController controller; - String imagePath; - String videoPath; - VideoPlayerController videoController; - VoidCallback videoPlayerListener; - bool enableAudio = true; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.inactive) { - controller?.dispose(); - } else if (state == AppLifecycleState.resumed) { - if (controller != null) { - onNewCameraSelected(controller.description); - } - } - } - - final GlobalKey _scaffoldKey = GlobalKey(); - +class MyApp extends StatefulWidget { @override - Widget build(BuildContext context) { - return Scaffold( - key: _scaffoldKey, - appBar: AppBar( - title: const Text('Camera example'), - ), - body: Column( - children: [ - Expanded( - child: Container( - child: Padding( - padding: const EdgeInsets.all(1.0), - child: Center( - child: _cameraPreviewWidget(), - ), - ), - decoration: BoxDecoration( - color: Colors.black, - border: Border.all( - color: controller != null && controller.value.isRecordingVideo - ? Colors.redAccent - : Colors.grey, - width: 3.0, - ), - ), - ), - ), - _captureControlRowWidget(), - _toggleAudioWidget(), - Padding( - padding: const EdgeInsets.all(5.0), - child: 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: TextStyle( - color: Colors.white, - fontSize: 24.0, - fontWeight: FontWeight.w900, - ), - ); - } else { - return AspectRatio( - aspectRatio: controller.value.aspectRatio, - child: CameraPreview(controller), - ); - } - } - - /// Toggle recording audio - Widget _toggleAudioWidget() { - return Padding( - padding: const EdgeInsets.only(left: 25), - child: Row( - children: [ - const Text('Enable Audio:'), - Switch( - value: enableAudio, - onChanged: (bool value) { - enableAudio = value; - if (controller != null) { - onNewCameraSelected(controller.description); - } - }, - ), - ], - ), - ); - } - - /// Display the thumbnail of the captured image or video. - Widget _thumbnailWidget() { - return Expanded( - child: Align( - alignment: Alignment.centerRight, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - videoController == null && imagePath == null - ? Container() - : SizedBox( - child: (videoController == null) - ? Image.file(File(imagePath)) - : Container( - child: Center( - child: AspectRatio( - aspectRatio: - videoController.value.size != null - ? videoController.value.aspectRatio - : 1.0, - child: VideoPlayer(videoController)), - ), - decoration: BoxDecoration( - border: 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 Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - mainAxisSize: MainAxisSize.max, - children: [ - IconButton( - icon: const Icon(Icons.camera_alt), - color: Colors.blue, - onPressed: controller != null && - controller.value.isInitialized && - !controller.value.isRecordingVideo - ? onTakePictureButtonPressed - : null, - ), - IconButton( - icon: const Icon(Icons.videocam), - color: Colors.blue, - onPressed: controller != null && - controller.value.isInitialized && - !controller.value.isRecordingVideo - ? onVideoRecordButtonPressed - : null, - ), - 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) { - return const Text('No camera found'); - } else { - for (CameraDescription cameraDescription in cameras) { - toggles.add( - SizedBox( - width: 90.0, - child: RadioListTile( - title: Icon(getCameraLensIcon(cameraDescription.lensDirection)), - groupValue: controller?.description, - value: cameraDescription, - onChanged: controller != null && controller.value.isRecordingVideo - ? null - : onNewCameraSelected, - ), - ), - ); - } - } - - return Row(children: toggles); - } - - String timestamp() => DateTime.now().millisecondsSinceEpoch.toString(); - - void showInSnackBar(String message) { - _scaffoldKey.currentState.showSnackBar(SnackBar(content: Text(message))); - } - - void onNewCameraSelected(CameraDescription cameraDescription) async { - if (controller != null) { - await controller.dispose(); - } - controller = CameraController( - cameraDescription, - ResolutionPreset.high, - enableAudio: enableAudio, - ); - - // 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); - } - - if (mounted) { - setState(() {}); - } - } - - void onTakePictureButtonPressed() { - takePicture().then((String filePath) { - if (mounted) { - setState(() { - imagePath = filePath; - videoController?.dispose(); - videoController = null; - }); - if (filePath != null) showInSnackBar('Picture saved to $filePath'); - } - }); - } - - void onVideoRecordButtonPressed() { - startVideoRecording().then((String filePath) { - if (mounted) setState(() {}); - if (filePath != null) showInSnackBar('Saving video to $filePath'); - }); - } - - 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 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 = - VideoPlayerController.file(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); - } - }; - 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 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}'); - } + _MyAppState createState() => _MyAppState(); } -class CameraApp extends StatelessWidget { +class _MyAppState extends State { @override Widget build(BuildContext context) { - return MaterialApp( - home: CameraExampleHome(), - ); - } -} - -List cameras; - -Future main() async { - // Fetch the available cameras before initializing the app. - try { - cameras = await availableCameras(); - } on CameraException catch (e) { - logError(e.code, e.description); + return Container(); } - runApp(CameraApp()); } diff --git a/packages/camera/example/pubspec.yaml b/packages/camera/example/pubspec.yaml index 834fe1b98cee..ce2a72631b05 100644 --- a/packages/camera/example/pubspec.yaml +++ b/packages/camera/example/pubspec.yaml @@ -10,9 +10,5 @@ dependencies: sdk: flutter video_player: ^0.10.0 -dev_dependencies: - flutter_test: - sdk: flutter - flutter: uses-material-design: true diff --git a/packages/camera/lib/camera.dart b/packages/camera/lib/camera.dart index 6364e1d68100..ab135079d2dd 100644 --- a/packages/camera/lib/camera.dart +++ b/packages/camera/lib/camera.dart @@ -1,497 +1,8 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; - -part 'camera_image.dart'; - -final MethodChannel _channel = const MethodChannel('plugins.flutter.io/camera'); - -enum CameraLensDirection { front, back, external } - -enum ResolutionPreset { low, medium, high } - -typedef onLatestImageAvailable = Function(CameraImage image); - -/// Returns the resolution preset as a String. -String serializeResolutionPreset(ResolutionPreset resolutionPreset) { - switch (resolutionPreset) { - case ResolutionPreset.high: - return 'high'; - case ResolutionPreset.medium: - return 'medium'; - case ResolutionPreset.low: - return 'low'; - } - throw ArgumentError('Unknown ResolutionPreset value'); -} - -CameraLensDirection _parseCameraLensDirection(String string) { - switch (string) { - case 'front': - return CameraLensDirection.front; - case 'back': - return CameraLensDirection.back; - case 'external': - return CameraLensDirection.external; - } - throw ArgumentError('Unknown CameraLensDirection value'); -} - -/// Completes with a list of available cameras. -/// -/// May throw a [CameraException]. -Future> availableCameras() async { - try { - final List> cameras = await _channel - .invokeListMethod>('availableCameras'); - return cameras.map((Map camera) { - return CameraDescription( - name: camera['name'], - lensDirection: _parseCameraLensDirection(camera['lensFacing']), - sensorOrientation: camera['sensorOrientation'], - ); - }).toList(); - } on PlatformException catch (e) { - throw CameraException(e.code, e.message); - } -} - -class CameraDescription { - CameraDescription({this.name, this.lensDirection, this.sensorOrientation}); - - final String name; - final CameraLensDirection lensDirection; - - /// Clockwise angle through which the output image needs to be rotated to be upright on the device screen in its native orientation. - /// - /// **Range of valid values:** - /// 0, 90, 180, 270 - /// - /// On Android, also defines the direction of rolling shutter readout, which - /// is from top to bottom in the sensor's coordinate system. - final int sensorOrientation; - - @override - bool operator ==(Object o) { - return o is CameraDescription && - o.name == name && - o.lensDirection == lensDirection; - } - - @override - int get hashCode { - return hashValues(name, lensDirection); - } - - @override - String toString() { - return '$runtimeType($name, $lensDirection, $sensorOrientation)'; - } -} - -/// This is thrown when the plugin reports an error. -class CameraException implements Exception { - CameraException(this.code, this.description); - - String code; - String description; - - @override - String toString() => '$runtimeType($code, $description)'; -} - -// Build the UI texture view of the video data with textureId. -class CameraPreview extends StatelessWidget { - const CameraPreview(this.controller); - - final CameraController controller; - - @override - Widget build(BuildContext context) { - return controller.value.isInitialized - ? Texture(textureId: controller._textureId) - : Container(); - } -} - -/// The state of a [CameraController]. -class CameraValue { - const CameraValue({ - this.isInitialized, - this.errorDescription, - this.previewSize, - this.isRecordingVideo, - this.isTakingPicture, - this.isStreamingImages, - }); - - const CameraValue.uninitialized() - : this( - isInitialized: false, - isRecordingVideo: false, - isTakingPicture: false, - isStreamingImages: false); - - /// True after [CameraController.initialize] has completed successfully. - 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; - - /// True when images from the camera are being streamed. - final bool isStreamingImages; - - final String errorDescription; - - /// The size of the preview in pixels. - /// - /// Is `null` until [isInitialized] is `true`. - final Size previewSize; - - /// Convenience getter for `previewSize.height / previewSize.width`. - /// - /// Can only be called when [initialize] is done. - double get aspectRatio => previewSize.height / previewSize.width; - - bool get hasError => errorDescription != null; - - CameraValue copyWith({ - bool isInitialized, - bool isRecordingVideo, - bool isTakingPicture, - bool isStreamingImages, - String errorDescription, - Size previewSize, - }) { - return CameraValue( - isInitialized: isInitialized ?? this.isInitialized, - errorDescription: errorDescription, - previewSize: previewSize ?? this.previewSize, - isRecordingVideo: isRecordingVideo ?? this.isRecordingVideo, - isTakingPicture: isTakingPicture ?? this.isTakingPicture, - isStreamingImages: isStreamingImages ?? this.isStreamingImages, - ); - } - - @override - String toString() { - return '$runtimeType(' - 'isRecordingVideo: $isRecordingVideo, ' - 'isRecordingVideo: $isRecordingVideo, ' - 'isInitialized: $isInitialized, ' - 'errorDescription: $errorDescription, ' - 'previewSize: $previewSize, ' - 'isStreamingImages: $isStreamingImages)'; - } -} - -/// Controls a device camera. -/// -/// Use [availableCameras] to get a list of available cameras. -/// -/// Before using a [CameraController] a call to [initialize] must complete. -/// -/// To show the camera preview on the screen use a [CameraPreview] widget. -class CameraController extends ValueNotifier { - CameraController( - this.description, - this.resolutionPreset, { - this.enableAudio = true, - }) : super(const CameraValue.uninitialized()); - - final CameraDescription description; - final ResolutionPreset resolutionPreset; - - /// Whether to include audio when recording a video. - final bool enableAudio; - - int _textureId; - bool _isDisposed = false; - StreamSubscription _eventSubscription; - StreamSubscription _imageStreamSubscription; - Completer _creatingCompleter; - - /// Initializes the camera on the device. - /// - /// Throws a [CameraException] if the initialization fails. - Future initialize() async { - if (_isDisposed) { - return Future.value(); - } - try { - _creatingCompleter = Completer(); - final Map reply = - await _channel.invokeMapMethod( - 'initialize', - { - 'cameraName': description.name, - 'resolutionPreset': serializeResolutionPreset(resolutionPreset), - 'enableAudio': enableAudio, - }, - ); - _textureId = reply['textureId']; - value = value.copyWith( - isInitialized: true, - previewSize: Size( - reply['previewWidth'].toDouble(), - reply['previewHeight'].toDouble(), - ), - ); - } on PlatformException catch (e) { - throw CameraException(e.code, e.message); - } - _eventSubscription = - EventChannel('flutter.io/cameraPlugin/cameraEvents$_textureId') - .receiveBroadcastStream() - .listen(_listener); - _creatingCompleter.complete(); - return _creatingCompleter.future; - } - - /// Prepare the capture session for video recording. - /// - /// Use of this method is optional, but it may be called for performance - /// reasons on iOS. - /// - /// Preparing audio can cause a minor delay in the CameraPreview view on iOS. - /// If video recording is intended, calling this early eliminates this delay - /// that would otherwise be experienced when video recording is started. - /// This operation is a no-op on Android. - /// - /// Throws a [CameraException] if the prepare fails. - Future prepareForVideoRecording() async { - await _channel.invokeMethod('prepareForVideoRecording'); - } - - /// 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 (_isDisposed) { - return; - } - - switch (map['eventType']) { - case 'error': - value = value.copyWith(errorDescription: event['errorDescription']); - break; - case 'cameraClosing': - value = value.copyWith(isRecordingVideo: false); - break; - } - } - - /// Captures an image and saves it to [path]. - /// - /// 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 takePicture(String path) async { - if (!value.isInitialized || _isDisposed) { - throw CameraException( - 'Uninitialized CameraController.', - 'takePicture was called on uninitialized CameraController', - ); - } - if (value.isTakingPicture) { - throw CameraException( - 'Previous capture has not returned yet.', - 'takePicture was called before the previous capture returned.', - ); - } - try { - value = value.copyWith(isTakingPicture: true); - await _channel.invokeMethod( - 'takePicture', - {'textureId': _textureId, 'path': path}, - ); - value = value.copyWith(isTakingPicture: false); - } on PlatformException catch (e) { - value = value.copyWith(isTakingPicture: false); - throw CameraException(e.code, e.message); - } - } - - /// Start streaming images from platform camera. - /// - /// Settings for capturing images on iOS and Android is set to always use the - /// latest image available from the camera and will drop all other images. - /// - /// When running continuously with [CameraPreview] widget, this function runs - /// best with [ResolutionPreset.low]. Running on [ResolutionPreset.high] can - /// have significant frame rate drops for [CameraPreview] on lower end - /// devices. - /// - /// Throws a [CameraException] if image streaming or video recording has - /// already started. - // TODO(bmparr): Add settings for resolution and fps. - Future startImageStream(onLatestImageAvailable onAvailable) async { - if (!value.isInitialized || _isDisposed) { - throw CameraException( - 'Uninitialized CameraController', - 'startImageStream was called on uninitialized CameraController.', - ); - } - if (value.isRecordingVideo) { - throw CameraException( - 'A video recording is already started.', - 'startImageStream was called while a video is being recorded.', - ); - } - if (value.isStreamingImages) { - throw CameraException( - 'A camera has started streaming images.', - 'startImageStream was called while a camera was streaming images.', - ); - } - - try { - await _channel.invokeMethod('startImageStream'); - value = value.copyWith(isStreamingImages: true); - } on PlatformException catch (e) { - throw CameraException(e.code, e.message); - } - const EventChannel cameraEventChannel = - EventChannel('plugins.flutter.io/camera/imageStream'); - _imageStreamSubscription = - cameraEventChannel.receiveBroadcastStream().listen( - (dynamic imageData) { - onAvailable(CameraImage._fromPlatformData(imageData)); - }, - ); - } - - /// Stop streaming images from platform camera. - /// - /// Throws a [CameraException] if image streaming was not started or video - /// recording was started. - Future stopImageStream() async { - if (!value.isInitialized || _isDisposed) { - throw CameraException( - 'Uninitialized CameraController', - 'stopImageStream was called on uninitialized CameraController.', - ); - } - if (value.isRecordingVideo) { - throw CameraException( - 'A video recording is already started.', - 'stopImageStream was called while a video is being recorded.', - ); - } - if (!value.isStreamingImages) { - throw CameraException( - 'No camera is streaming images', - 'stopImageStream was called when no camera is streaming images.', - ); - } - - try { - value = value.copyWith(isStreamingImages: false); - await _channel.invokeMethod('stopImageStream'); - } on PlatformException catch (e) { - throw CameraException(e.code, e.message); - } - - _imageStreamSubscription.cancel(); - _imageStreamSubscription = null; - } - - /// 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. - /// - /// Throws a [CameraException] if the capture fails. - Future startVideoRecording(String filePath) async { - if (!value.isInitialized || _isDisposed) { - throw CameraException( - 'Uninitialized CameraController', - 'startVideoRecording was called on uninitialized CameraController', - ); - } - if (value.isRecordingVideo) { - throw CameraException( - 'A video recording is already started.', - 'startVideoRecording was called when a recording is already started.', - ); - } - if (value.isStreamingImages) { - throw CameraException( - 'A camera has started streaming images.', - 'startVideoRecording was called while a camera was streaming images.', - ); - } - - try { - await _channel.invokeMethod( - 'startVideoRecording', - {'textureId': _textureId, 'filePath': filePath}, - ); - value = value.copyWith(isRecordingVideo: true); - } on PlatformException catch (e) { - throw CameraException(e.code, e.message); - } - } - - /// Stop recording. - Future stopVideoRecording() async { - if (!value.isInitialized || _isDisposed) { - throw CameraException( - 'Uninitialized CameraController', - 'stopVideoRecording was called on uninitialized CameraController', - ); - } - if (!value.isRecordingVideo) { - throw 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 CameraException(e.code, e.message); - } - } - - /// Releases the resources of this camera. - @override - Future dispose() async { - if (_isDisposed) { - return; - } - _isDisposed = true; - super.dispose(); - if (_creatingCompleter != null) { - await _creatingCompleter.future; - await _channel.invokeMethod( - 'dispose', - {'textureId': _textureId}, - ); - await _eventSubscription?.cancel(); - } - } -} +export 'src/camera_controller.dart'; +export 'src/camera_testing.dart'; +export 'src/common/camera_interface.dart'; +export 'src/common/native_texture.dart'; diff --git a/packages/camera/lib/camera_image.dart b/packages/camera/lib/camera_image.dart deleted file mode 100644 index cebc14873f52..000000000000 --- a/packages/camera/lib/camera_image.dart +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright 2018 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -part of 'camera.dart'; - -/// A single color plane of image data. -/// -/// The number and meaning of the planes in an image are determined by the -/// format of the Image. -class Plane { - Plane._fromPlatformData(Map data) - : bytes = data['bytes'], - bytesPerPixel = data['bytesPerPixel'], - bytesPerRow = data['bytesPerRow'], - height = data['height'], - width = data['width']; - - /// Bytes representing this plane. - final Uint8List bytes; - - /// The distance between adjacent pixel samples on Android, in bytes. - /// - /// Will be `null` on iOS. - final int bytesPerPixel; - - /// The row stride for this color plane, in bytes. - final int bytesPerRow; - - /// Height of the pixel buffer on iOS. - /// - /// Will be `null` on Android - final int height; - - /// Width of the pixel buffer on iOS. - /// - /// Will be `null` on Android. - final int width; -} - -// TODO:(bmparr) Turn [ImageFormatGroup] to a class with int values. -/// Group of image formats that are comparable across Android and iOS platforms. -enum ImageFormatGroup { - /// The image format does not fit into any specific group. - unknown, - - /// Multi-plane YUV 420 format. - /// - /// This format is a generic YCbCr format, capable of describing any 4:2:0 - /// chroma-subsampled planar or semiplanar buffer (but not fully interleaved), - /// with 8 bits per color sample. - /// - /// On Android, this is `android.graphics.ImageFormat.YUV_420_888`. See - /// https://developer.android.com/reference/android/graphics/ImageFormat.html#YUV_420_888 - /// - /// On iOS, this is `kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange`. See - /// https://developer.apple.com/documentation/corevideo/1563591-pixel_format_identifiers/kcvpixelformattype_420ypcbcr8biplanarvideorange?language=objc - yuv420, - - /// 32-bit BGRA. - /// - /// On iOS, this is `kCVPixelFormatType_32BGRA`. See - /// https://developer.apple.com/documentation/corevideo/1563591-pixel_format_identifiers/kcvpixelformattype_32bgra?language=objc - bgra8888, -} - -/// Describes how pixels are represented in an image. -class ImageFormat { - ImageFormat._fromPlatformData(this.raw) : group = _asImageFormatGroup(raw); - - /// Describes the format group the raw image format falls into. - final ImageFormatGroup group; - - /// Raw version of the format from the Android or iOS platform. - /// - /// On Android, this is an `int` from class `android.graphics.ImageFormat`. See - /// https://developer.android.com/reference/android/graphics/ImageFormat - /// - /// On iOS, this is a `FourCharCode` constant from Pixel Format Identifiers. - /// See https://developer.apple.com/documentation/corevideo/1563591-pixel_format_identifiers?language=objc - final dynamic raw; -} - -ImageFormatGroup _asImageFormatGroup(dynamic rawFormat) { - if (defaultTargetPlatform == TargetPlatform.android) { - // android.graphics.ImageFormat.YUV_420_888 - if (rawFormat == 35) { - return ImageFormatGroup.yuv420; - } - } - - if (defaultTargetPlatform == TargetPlatform.iOS) { - switch (rawFormat) { - // kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange - case 875704438: - return ImageFormatGroup.yuv420; - // kCVPixelFormatType_32BGRA - case 1111970369: - return ImageFormatGroup.bgra8888; - } - } - - return ImageFormatGroup.unknown; -} - -/// A single complete image buffer from the platform camera. -/// -/// This class allows for direct application access to the pixel data of an -/// Image through one or more [Uint8List]. Each buffer is encapsulated in a -/// [Plane] that describes the layout of the pixel data in that plane. The -/// [CameraImage] is not directly usable as a UI resource. -/// -/// Although not all image formats are planar on iOS, we treat 1-dimensional -/// images as single planar images. -class CameraImage { - CameraImage._fromPlatformData(Map data) - : format = ImageFormat._fromPlatformData(data['format']), - height = data['height'], - width = data['width'], - planes = List.unmodifiable(data['planes'] - .map((dynamic planeData) => Plane._fromPlatformData(planeData))); - - /// Format of the image provided. - /// - /// Determines the number of planes needed to represent the image, and - /// the general layout of the pixel data in each [Uint8List]. - final ImageFormat format; - - /// Height of the image in pixels. - /// - /// For formats where some color channels are subsampled, this is the height - /// of the largest-resolution plane. - final int height; - - /// Width of the image in pixels. - /// - /// For formats where some color channels are subsampled, this is the width - /// of the largest-resolution plane. - final int width; - - /// The pixels planes for this image. - /// - /// The number of planes is determined by the format of the image. - final List planes; -} diff --git a/packages/camera/lib/src/camera_controller.dart b/packages/camera/lib/src/camera_controller.dart new file mode 100644 index 000000000000..cd07dc50d243 --- /dev/null +++ b/packages/camera/lib/src/camera_controller.dart @@ -0,0 +1,110 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'common/camera_interface.dart'; + +/// Controls a device camera. +/// +/// Use [CameraController.availableCameras] to get a list of available cameras. +/// +/// This class is used as a simple interface that works for Android and iOS. +/// +/// When using iOS, simultaneously calling [start] on two [CameraController]s +/// will throw a [PlatformException]. +/// +/// When using Android, simultaneously calling [start] on two +/// [CameraController]s may throw a [PlatformException] depending on the +/// hardware resources of the device. +class CameraController { + /// Default constructor. + /// + /// Use [CameraController.availableCameras] to get a list of available + /// cameras. + /// + /// This will choose the best [CameraConfigurator] for the current device. + factory CameraController({@required CameraDescription description}) { + assert(description != null); + return CameraController._( + description: description, + configurator: _createDefaultConfigurator(description), + api: _getCameraApi(description), + ); + } + + CameraController._({ + @required this.description, + @required this.configurator, + @required this.api, + }) : assert(description != null), + assert(configurator != null), + assert(api != null); + + /// Constructor for defining your own [CameraConfigurator]. + /// + /// Use [CameraController.availableCameras] to get a list of available + /// cameras. + factory CameraController.customConfigurator({ + @required CameraDescription description, + @required CameraConfigurator configurator, + }) { + return CameraController._( + description: description, + configurator: configurator, + api: _getCameraApi(description), + ); + } + + /// Details for the camera this controller accesses. + final CameraDescription description; + + /// Configurator used to control the camera. + final CameraConfigurator configurator; + + /// Api used by the [configurator]. + final CameraApi api; + + /// Retrieves a list of available cameras for the current device. + /// + /// This will choose the best [CameraAPI] for the current device. + static Future> availableCameras() async { + throw UnimplementedError('$defaultTargetPlatform not supported'); + } + + /// Begins the flow of data between the inputs and outputs connected the camera instance. + Future start() => configurator.start(); + + /// Stops the flow of data between the inputs and outputs connected the camera instance. + Future stop() => configurator.stop(); + + /// Deallocate all resources and disables further use of the controller. + Future dispose() => configurator.dispose(); + + static CameraConfigurator _createDefaultConfigurator( + CameraDescription description, + ) { + final CameraApi api = _getCameraApi(description); + switch (api) { + case CameraApi.android: + throw UnimplementedError(); + case CameraApi.iOS: + throw UnimplementedError(); + case CameraApi.supportAndroid: + throw UnimplementedError(); + } + + return null; + } + + static CameraApi _getCameraApi(CameraDescription description) { + throw ArgumentError.value( + description.runtimeType, + 'description.runtimeType', + 'Failed to get $CameraApi from', + ); + } +} diff --git a/packages/camera/lib/src/camera_testing.dart b/packages/camera/lib/src/camera_testing.dart new file mode 100644 index 000000000000..8022216ff8c8 --- /dev/null +++ b/packages/camera/lib/src/camera_testing.dart @@ -0,0 +1,17 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'common/camera_channel.dart'; + +@visibleForTesting +class CameraTesting { + CameraTesting._(); + + static final MethodChannel channel = CameraChannel.channel; + static int get nextHandle => CameraChannel.nextHandle; + static set nextHandle(int handle) => CameraChannel.nextHandle = handle; +} diff --git a/packages/camera/lib/src/common/camera_channel.dart b/packages/camera/lib/src/common/camera_channel.dart new file mode 100644 index 000000000000..0d090c1e2f81 --- /dev/null +++ b/packages/camera/lib/src/common/camera_channel.dart @@ -0,0 +1,38 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/services.dart'; + +typedef CameraCallback = void Function(dynamic result); + +// Non exported class +class CameraChannel { + static final Map callbacks = {}; + + static final MethodChannel channel = MethodChannel( + 'flutter.plugins.io/camera', + )..setMethodCallHandler( + (MethodCall call) async { + assert(call.method == 'handleCallback'); + + final int handle = call.arguments['handle']; + if (callbacks[handle] != null) callbacks[handle](call.arguments); + }, + ); + + static int nextHandle = 0; + + static void registerCallback(int handle, CameraCallback callback) { + assert(handle != null); + assert(CameraCallback != null); + + assert(!callbacks.containsKey(handle)); + callbacks[handle] = callback; + } + + static void unregisterCallback(int handle) { + assert(handle != null); + callbacks.remove(handle); + } +} diff --git a/packages/camera/lib/src/common/camera_interface.dart b/packages/camera/lib/src/common/camera_interface.dart new file mode 100644 index 000000000000..8b5ca9d4e4a3 --- /dev/null +++ b/packages/camera/lib/src/common/camera_interface.dart @@ -0,0 +1,54 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +/// Available APIs compatible with [CameraController]. +enum CameraApi { + /// [Camera2](https://developer.android.com/reference/android/hardware/camera2/package-summary) + android, + + /// [AVFoundation](https://developer.apple.com/av-foundation/) + iOS, + + /// [Camera](https://developer.android.com/reference/android/hardware/Camera) + supportAndroid, +} + +/// Location of the camera on the device. +enum LensDirection { front, back, unknown } + +/// Abstract class used to create a common interface to describe a camera from different platform APIs. +/// +/// This provides information such as the [name] of the camera and [direction] +/// the lens face. +abstract class CameraDescription { + /// Location of the camera on the device. + LensDirection get direction; + + /// Identifier for this camera. + String get name; +} + +/// Abstract class used to create a common interface across platform APIs. +abstract class CameraConfigurator { + /// Texture id that can be used to send camera frames to a [Texture] widget. + /// + /// You must call [addPreviewTexture] first or this will only return null. + int get previewTextureId; + + /// Begins the flow of data between the inputs and outputs connected the camera instance. + /// + /// This will start updating the texture with id: [previewTextureId]. + Future start(); + + /// Stops the flow of data between the inputs and outputs connected the camera instance. + Future stop(); + + /// Dispose all resources and disables further use of this configurator. + Future dispose(); + + /// Retrieves a valid texture Id to be used with a [Texture] widget. + Future addPreviewTexture(); +} diff --git a/packages/camera/lib/src/common/camera_mixins.dart b/packages/camera/lib/src/common/camera_mixins.dart new file mode 100644 index 000000000000..bb27e4881d1f --- /dev/null +++ b/packages/camera/lib/src/common/camera_mixins.dart @@ -0,0 +1,19 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'camera_channel.dart'; + +mixin NativeMethodCallHandler { + /// Identifier for an object on the native side of the plugin. + /// + /// Only used internally and for debugging. + final int handle = CameraChannel.nextHandle++; +} + +mixin CameraMappable { + /// Creates a description of the object compatible with [PlatformChannel]s. + /// + /// Only used as an internal method and for debugging. + Map asMap(); +} diff --git a/packages/camera/lib/src/common/native_texture.dart b/packages/camera/lib/src/common/native_texture.dart new file mode 100644 index 000000000000..1deb7e3a10b6 --- /dev/null +++ b/packages/camera/lib/src/common/native_texture.dart @@ -0,0 +1,59 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'camera_channel.dart'; +import 'camera_mixins.dart'; + +/// Used to allocate a buffer for displaying a preview camera texture. +/// +/// This is used to for a developer to have a control over the +/// `TextureRegistry.SurfaceTextureEntry` (Android) and FlutterTexture (iOS). +/// This gives direct access to the textureId and can be reused with separate +/// camera instances. +/// +/// The [textureId] can be passed to a [Texture] widget. +class NativeTexture with CameraMappable { + NativeTexture._({@required int handle, @required this.textureId}) + : _handle = handle, + assert(handle != null), + assert(textureId != null); + + final int _handle; + + bool _isClosed = false; + + /// Id that can be passed to a [Texture] widget. + final int textureId; + + static Future allocate() async { + final int handle = CameraChannel.nextHandle++; + + final int textureId = await CameraChannel.channel.invokeMethod( + '$NativeTexture#allocate', + {'textureHandle': handle}, + ); + + return NativeTexture._(handle: handle, textureId: textureId); + } + + /// Deallocate this texture. + Future release() { + if (_isClosed) return Future.value(); + + _isClosed = true; + return CameraChannel.channel.invokeMethod( + '$NativeTexture#release', + {'handle': _handle}, + ); + } + + @override + Map asMap() { + return {'handle': _handle}; + } +} diff --git a/packages/camera/pubspec.yaml b/packages/camera/pubspec.yaml index 81a0e8f877a4..fec75944f8b3 100644 --- a/packages/camera/pubspec.yaml +++ b/packages/camera/pubspec.yaml @@ -2,7 +2,8 @@ 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.2+1 +version: 0.6.0 +publish_to: none authors: - Flutter Team - Luigi Agosti @@ -17,6 +18,8 @@ dependencies: sdk: flutter dev_dependencies: + flutter_test: + sdk: flutter path_provider: ^0.5.0 video_player: ^0.10.0 diff --git a/packages/camera/test/camera_test.dart b/packages/camera/test/camera_test.dart new file mode 100644 index 000000000000..a3a82f75ffed --- /dev/null +++ b/packages/camera/test/camera_test.dart @@ -0,0 +1,50 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:camera/src/camera_testing.dart'; +import 'package:camera/src/common/native_texture.dart'; + +void main() { + group('Camera', () { + final List log = []; + + setUpAll(() { + CameraTesting.channel + .setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + switch (methodCall.method) { + case 'NativeTexture#allocate': + return 15; + } + + throw ArgumentError.value( + methodCall.method, + 'methodCall.method', + 'No method found for', + ); + }); + }); + + setUp(() { + log.clear(); + CameraTesting.nextHandle = 0; + }); + + group('$NativeTexture', () { + test('allocate', () async { + final NativeTexture texture = await NativeTexture.allocate(); + + expect(texture.textureId, 15); + expect(log, [ + isMethodCall( + '$NativeTexture#allocate', + arguments: {'textureHandle': 0}, + ) + ]); + }); + }); + }); +}