diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index f68d6e3cffd..1a783b16391 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 0.10.4 +* Allows camera to be switched while video recording. * Updates minimum Flutter version to 3.3. * Aligns Dart and Flutter SDK constraints. diff --git a/packages/camera/camera/README.md b/packages/camera/camera/README.md index db70831aef1..095da3cbbe9 100644 --- a/packages/camera/camera/README.md +++ b/packages/camera/camera/README.md @@ -70,7 +70,7 @@ void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.inactive) { cameraController.dispose(); } else if (state == AppLifecycleState.resumed) { - onNewCameraSelected(cameraController.description); + _initializeCameraController(cameraController.description); } } ``` diff --git a/packages/camera/camera/example/integration_test/camera_test.dart b/packages/camera/camera/example/integration_test/camera_test.dart index f0cc67f0c06..6bef30e62c2 100644 --- a/packages/camera/camera/example/integration_test/camera_test.dart +++ b/packages/camera/camera/example/integration_test/camera_test.dart @@ -265,6 +265,45 @@ void main() { return completer.future; } + testWidgets('Set description while recording', (WidgetTester tester) async { + final List cameras = await availableCameras(); + if (cameras.length < 2) { + return; + } + + final CameraController controller = CameraController( + cameras[0], + ResolutionPreset.low, + enableAudio: false, + ); + + await controller.initialize(); + await controller.prepareForVideoRecording(); + + await controller.startVideoRecording(); + await controller.setDescription(cameras[1]); + + expect(controller.description, cameras[1]); + }); + + testWidgets('Set description', (WidgetTester tester) async { + final List cameras = await availableCameras(); + if (cameras.length < 2) { + return; + } + + final CameraController controller = CameraController( + cameras[0], + ResolutionPreset.low, + enableAudio: false, + ); + + await controller.initialize(); + await controller.setDescription(cameras[1]); + + expect(controller.description, cameras[1]); + }); + testWidgets( 'iOS image streaming with imageFormatGroup', (WidgetTester tester) async { diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart index 2fa2ae6de2d..73c9f052d48 100644 --- a/packages/camera/camera/example/lib/main.dart +++ b/packages/camera/camera/example/lib/main.dart @@ -120,7 +120,7 @@ class _CameraExampleHomeState extends State if (state == AppLifecycleState.inactive) { cameraController.dispose(); } else if (state == AppLifecycleState.resumed) { - onNewCameraSelected(cameraController.description); + _initializeCameraController(cameraController.description); } } // #enddocregion AppLifecycle @@ -597,10 +597,7 @@ class _CameraExampleHomeState extends State title: Icon(getCameraLensIcon(cameraDescription.lensDirection)), groupValue: controller?.description, value: cameraDescription, - onChanged: - controller != null && controller!.value.isRecordingVideo - ? null - : onChanged, + onChanged: onChanged, ), ), ); @@ -633,17 +630,15 @@ class _CameraExampleHomeState extends State } Future onNewCameraSelected(CameraDescription cameraDescription) async { - final CameraController? oldController = controller; - if (oldController != null) { - // `controller` needs to be set to null before getting disposed, - // to avoid a race condition when we use the controller that is being - // disposed. This happens when camera permission dialog shows up, - // which triggers `didChangeAppLifecycleState`, which disposes and - // re-creates the controller. - controller = null; - await oldController.dispose(); + if (controller != null) { + return controller!.setDescription(cameraDescription); + } else { + return _initializeCameraController(cameraDescription); } + } + Future _initializeCameraController( + CameraDescription cameraDescription) async { final CameraController cameraController = CameraController( cameraDescription, kIsWeb ? ResolutionPreset.max : ResolutionPreset.medium, diff --git a/packages/camera/camera/lib/src/camera_controller.dart b/packages/camera/camera/lib/src/camera_controller.dart index 7a396c1589f..69917d3f039 100644 --- a/packages/camera/camera/lib/src/camera_controller.dart +++ b/packages/camera/camera/lib/src/camera_controller.dart @@ -49,6 +49,7 @@ class CameraValue { required this.exposurePointSupported, required this.focusPointSupported, required this.deviceOrientation, + required this.description, this.lockedCaptureOrientation, this.recordingOrientation, this.isPreviewPaused = false, @@ -56,7 +57,7 @@ class CameraValue { }) : _isRecordingPaused = isRecordingPaused; /// Creates a new camera controller state for an uninitialized controller. - const CameraValue.uninitialized() + const CameraValue.uninitialized(CameraDescription description) : this( isInitialized: false, isRecordingVideo: false, @@ -70,6 +71,7 @@ class CameraValue { focusPointSupported: false, deviceOrientation: DeviceOrientation.portraitUp, isPreviewPaused: false, + description: description, ); /// True after [CameraController.initialize] has completed successfully. @@ -143,6 +145,9 @@ class CameraValue { /// The orientation of the currently running video recording. final DeviceOrientation? recordingOrientation; + /// The properties of the camera device controlled by this controller. + final CameraDescription description; + /// Creates a modified copy of the object. /// /// Explicitly specified fields get the specified value, all other fields get @@ -164,6 +169,7 @@ class CameraValue { Optional? lockedCaptureOrientation, Optional? recordingOrientation, bool? isPreviewPaused, + CameraDescription? description, Optional? previewPauseOrientation, }) { return CameraValue( @@ -188,6 +194,7 @@ class CameraValue { ? this.recordingOrientation : recordingOrientation.orNull, isPreviewPaused: isPreviewPaused ?? this.isPreviewPaused, + description: description ?? this.description, previewPauseOrientation: previewPauseOrientation == null ? this.previewPauseOrientation : previewPauseOrientation.orNull, @@ -211,7 +218,8 @@ class CameraValue { 'lockedCaptureOrientation: $lockedCaptureOrientation, ' 'recordingOrientation: $recordingOrientation, ' 'isPreviewPaused: $isPreviewPaused, ' - 'previewPausedOrientation: $previewPauseOrientation)'; + 'previewPausedOrientation: $previewPauseOrientation, ' + 'description: $description)'; } } @@ -225,14 +233,14 @@ class CameraValue { class CameraController extends ValueNotifier { /// Creates a new camera controller in an uninitialized state. CameraController( - this.description, + CameraDescription description, this.resolutionPreset, { this.enableAudio = true, this.imageFormatGroup, - }) : super(const CameraValue.uninitialized()); + }) : super(CameraValue.uninitialized(description)); /// The properties of the camera device controlled by this controller. - final CameraDescription description; + CameraDescription get description => value.description; /// The resolution this controller is targeting. /// @@ -274,7 +282,12 @@ class CameraController extends ValueNotifier { /// Initializes the camera on the device. /// /// Throws a [CameraException] if the initialization fails. - Future initialize() async { + Future initialize() => _initializeWithDescription(description); + + /// Initializes the camera on the device with the specified description. + /// + /// Throws a [CameraException] if the initialization fails. + Future _initializeWithDescription(CameraDescription description) async { if (_isDisposed) { throw CameraException( 'Disposed CameraController', @@ -313,6 +326,7 @@ class CameraController extends ValueNotifier { value = value.copyWith( isInitialized: true, + description: description, previewSize: await initializeCompleter.future .then((CameraInitializedEvent event) => Size( event.previewWidth, @@ -380,6 +394,18 @@ class CameraController extends ValueNotifier { } } + /// Sets the description of the camera. + /// + /// Throws a [CameraException] if setting the description fails. + Future setDescription(CameraDescription description) async { + if (value.isRecordingVideo) { + await CameraPlatform.instance.setDescriptionWhileRecording(description); + value = value.copyWith(description: description); + } else { + await _initializeWithDescription(description); + } + } + /// Captures an image and returns the file where it was saved. /// /// Throws a [CameraException] if the capture fails. diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index f2196e740a7..839f064bba1 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -4,7 +4,7 @@ description: A Flutter plugin for controlling the camera. Supports previewing Dart. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.10.3+2 +version: 0.10.4 environment: sdk: ">=2.18.0 <4.0.0" @@ -21,9 +21,9 @@ flutter: default_package: camera_web dependencies: - camera_android: ^0.10.1 - camera_avfoundation: ^0.9.9 - camera_platform_interface: ^2.3.2 + camera_android: ^0.10.5 + camera_avfoundation: ^0.9.13 + camera_platform_interface: ^2.4.0 camera_web: ^0.3.1 flutter: sdk: flutter diff --git a/packages/camera/camera/test/camera_preview_test.dart b/packages/camera/camera/test/camera_preview_test.dart index 6677fcf9039..c73e1816445 100644 --- a/packages/camera/camera/test/camera_preview_test.dart +++ b/packages/camera/camera/test/camera_preview_test.dart @@ -11,7 +11,10 @@ import 'package:flutter_test/flutter_test.dart'; class FakeController extends ValueNotifier implements CameraController { - FakeController() : super(const CameraValue.uninitialized()); + FakeController() : super(const CameraValue.uninitialized(fakeDescription)); + + static const CameraDescription fakeDescription = CameraDescription( + name: '', lensDirection: CameraLensDirection.back, sensorOrientation: 0); @override Future dispose() async { @@ -29,10 +32,6 @@ class FakeController extends ValueNotifier @override void debugCheckIsDisposed() {} - @override - CameraDescription get description => const CameraDescription( - name: '', lensDirection: CameraLensDirection.back, sensorOrientation: 0); - @override bool get enableAudio => false; @@ -117,6 +116,12 @@ class FakeController extends ValueNotifier @override Future resumePreview() async {} + + @override + Future setDescription(CameraDescription description) async {} + + @override + CameraDescription get description => value.description; } void main() { diff --git a/packages/camera/camera/test/camera_value_test.dart b/packages/camera/camera/test/camera_value_test.dart index 37168dbd48d..e23b865f30a 100644 --- a/packages/camera/camera/test/camera_value_test.dart +++ b/packages/camera/camera/test/camera_value_test.dart @@ -13,6 +13,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'camera_preview_test.dart'; + void main() { group('camera_value', () { test('Can be created', () { @@ -32,6 +34,7 @@ void main() { recordingOrientation: DeviceOrientation.portraitUp, focusPointSupported: true, previewPauseOrientation: DeviceOrientation.portraitUp, + description: FakeController.fakeDescription, ); expect(cameraValue, isA()); @@ -54,7 +57,8 @@ void main() { }); test('Can be created as uninitialized', () { - const CameraValue cameraValue = CameraValue.uninitialized(); + const CameraValue cameraValue = + CameraValue.uninitialized(FakeController.fakeDescription); expect(cameraValue, isA()); expect(cameraValue.isInitialized, isFalse); @@ -76,7 +80,8 @@ void main() { }); test('Can be copied with isInitialized', () { - const CameraValue cv = CameraValue.uninitialized(); + const CameraValue cv = + CameraValue.uninitialized(FakeController.fakeDescription); final CameraValue cameraValue = cv.copyWith(isInitialized: true); expect(cameraValue, isA()); @@ -99,7 +104,8 @@ void main() { }); test('Has aspectRatio after setting size', () { - const CameraValue cv = CameraValue.uninitialized(); + const CameraValue cv = + CameraValue.uninitialized(FakeController.fakeDescription); final CameraValue cameraValue = cv.copyWith(isInitialized: true, previewSize: const Size(20, 10)); @@ -107,7 +113,8 @@ void main() { }); test('hasError is true after setting errorDescription', () { - const CameraValue cv = CameraValue.uninitialized(); + const CameraValue cv = + CameraValue.uninitialized(FakeController.fakeDescription); final CameraValue cameraValue = cv.copyWith(errorDescription: 'error'); expect(cameraValue.hasError, isTrue); @@ -115,7 +122,8 @@ void main() { }); test('Recording paused is false when not recording', () { - const CameraValue cv = CameraValue.uninitialized(); + const CameraValue cv = + CameraValue.uninitialized(FakeController.fakeDescription); final CameraValue cameraValue = cv.copyWith( isInitialized: true, isRecordingVideo: false, @@ -126,25 +134,27 @@ void main() { test('toString() works as expected', () { const CameraValue cameraValue = CameraValue( - isInitialized: false, - previewSize: Size(10, 10), - isRecordingPaused: false, - isRecordingVideo: false, - isTakingPicture: false, - isStreamingImages: false, - flashMode: FlashMode.auto, - exposureMode: ExposureMode.auto, - focusMode: FocusMode.auto, - exposurePointSupported: true, - focusPointSupported: true, - deviceOrientation: DeviceOrientation.portraitUp, - lockedCaptureOrientation: DeviceOrientation.portraitUp, - recordingOrientation: DeviceOrientation.portraitUp, - isPreviewPaused: true, - previewPauseOrientation: DeviceOrientation.portraitUp); + isInitialized: false, + previewSize: Size(10, 10), + isRecordingPaused: false, + isRecordingVideo: false, + isTakingPicture: false, + isStreamingImages: false, + flashMode: FlashMode.auto, + exposureMode: ExposureMode.auto, + focusMode: FocusMode.auto, + exposurePointSupported: true, + focusPointSupported: true, + deviceOrientation: DeviceOrientation.portraitUp, + lockedCaptureOrientation: DeviceOrientation.portraitUp, + recordingOrientation: DeviceOrientation.portraitUp, + isPreviewPaused: true, + previewPauseOrientation: DeviceOrientation.portraitUp, + description: FakeController.fakeDescription, + ); expect(cameraValue.toString(), - 'CameraValue(isRecordingVideo: false, isInitialized: false, errorDescription: null, previewSize: Size(10.0, 10.0), isStreamingImages: false, flashMode: FlashMode.auto, exposureMode: ExposureMode.auto, focusMode: FocusMode.auto, exposurePointSupported: true, focusPointSupported: true, deviceOrientation: DeviceOrientation.portraitUp, lockedCaptureOrientation: DeviceOrientation.portraitUp, recordingOrientation: DeviceOrientation.portraitUp, isPreviewPaused: true, previewPausedOrientation: DeviceOrientation.portraitUp)'); + 'CameraValue(isRecordingVideo: false, isInitialized: false, errorDescription: null, previewSize: Size(10.0, 10.0), isStreamingImages: false, flashMode: FlashMode.auto, exposureMode: ExposureMode.auto, focusMode: FocusMode.auto, exposurePointSupported: true, focusPointSupported: true, deviceOrientation: DeviceOrientation.portraitUp, lockedCaptureOrientation: DeviceOrientation.portraitUp, recordingOrientation: DeviceOrientation.portraitUp, isPreviewPaused: true, previewPausedOrientation: DeviceOrientation.portraitUp, description: CameraDescription(, CameraLensDirection.back, 0))'); }); }); }