Skip to content

[camera] Reland implementation of flip camera while recording. App facing changes #3496

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Apr 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/camera/camera/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion packages/camera/camera/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,45 @@ void main() {
return completer.future;
}

testWidgets('Set description while recording', (WidgetTester tester) async {
final List<CameraDescription> 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<CameraDescription> 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 {
Expand Down
23 changes: 9 additions & 14 deletions packages/camera/camera/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ class _CameraExampleHomeState extends State<CameraExampleHome>
if (state == AppLifecycleState.inactive) {
cameraController.dispose();
} else if (state == AppLifecycleState.resumed) {
onNewCameraSelected(cameraController.description);
_initializeCameraController(cameraController.description);
}
}
// #enddocregion AppLifecycle
Expand Down Expand Up @@ -597,10 +597,7 @@ class _CameraExampleHomeState extends State<CameraExampleHome>
title: Icon(getCameraLensIcon(cameraDescription.lensDirection)),
groupValue: controller?.description,
value: cameraDescription,
onChanged:
controller != null && controller!.value.isRecordingVideo
? null
: onChanged,
onChanged: onChanged,
),
),
);
Expand Down Expand Up @@ -633,17 +630,15 @@ class _CameraExampleHomeState extends State<CameraExampleHome>
}

Future<void> 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<void> _initializeCameraController(
CameraDescription cameraDescription) async {
final CameraController cameraController = CameraController(
cameraDescription,
kIsWeb ? ResolutionPreset.max : ResolutionPreset.medium,
Expand Down
38 changes: 32 additions & 6 deletions packages/camera/camera/lib/src/camera_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,15 @@ class CameraValue {
required this.exposurePointSupported,
required this.focusPointSupported,
required this.deviceOrientation,
required this.description,
this.lockedCaptureOrientation,
this.recordingOrientation,
this.isPreviewPaused = false,
this.previewPauseOrientation,
}) : _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,
Expand All @@ -70,6 +71,7 @@ class CameraValue {
focusPointSupported: false,
deviceOrientation: DeviceOrientation.portraitUp,
isPreviewPaused: false,
description: description,
);

/// True after [CameraController.initialize] has completed successfully.
Expand Down Expand Up @@ -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
Expand All @@ -164,6 +169,7 @@ class CameraValue {
Optional<DeviceOrientation>? lockedCaptureOrientation,
Optional<DeviceOrientation>? recordingOrientation,
bool? isPreviewPaused,
CameraDescription? description,
Optional<DeviceOrientation>? previewPauseOrientation,
}) {
return CameraValue(
Expand All @@ -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,
Expand All @@ -211,7 +218,8 @@ class CameraValue {
'lockedCaptureOrientation: $lockedCaptureOrientation, '
'recordingOrientation: $recordingOrientation, '
'isPreviewPaused: $isPreviewPaused, '
'previewPausedOrientation: $previewPauseOrientation)';
'previewPausedOrientation: $previewPauseOrientation, '
'description: $description)';
}
}

Expand All @@ -225,14 +233,14 @@ class CameraValue {
class CameraController extends ValueNotifier<CameraValue> {
/// 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.
///
Expand Down Expand Up @@ -274,7 +282,12 @@ class CameraController extends ValueNotifier<CameraValue> {
/// Initializes the camera on the device.
///
/// Throws a [CameraException] if the initialization fails.
Future<void> initialize() async {
Future<void> initialize() => _initializeWithDescription(description);

/// Initializes the camera on the device with the specified description.
///
/// Throws a [CameraException] if the initialization fails.
Future<void> _initializeWithDescription(CameraDescription description) async {
if (_isDisposed) {
throw CameraException(
'Disposed CameraController',
Expand Down Expand Up @@ -313,6 +326,7 @@ class CameraController extends ValueNotifier<CameraValue> {

value = value.copyWith(
isInitialized: true,
description: description,
previewSize: await initializeCompleter.future
.then((CameraInitializedEvent event) => Size(
event.previewWidth,
Expand Down Expand Up @@ -380,6 +394,18 @@ class CameraController extends ValueNotifier<CameraValue> {
}
}

/// Sets the description of the camera.
///
/// Throws a [CameraException] if setting the description fails.
Future<void> 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.
Expand Down
8 changes: 4 additions & 4 deletions packages/camera/camera/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
15 changes: 10 additions & 5 deletions packages/camera/camera/test/camera_preview_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import 'package:flutter_test/flutter_test.dart';

class FakeController extends ValueNotifier<CameraValue>
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<void> dispose() async {
Expand All @@ -29,10 +32,6 @@ class FakeController extends ValueNotifier<CameraValue>
@override
void debugCheckIsDisposed() {}

@override
CameraDescription get description => const CameraDescription(
name: '', lensDirection: CameraLensDirection.back, sensorOrientation: 0);

@override
bool get enableAudio => false;

Expand Down Expand Up @@ -117,6 +116,12 @@ class FakeController extends ValueNotifier<CameraValue>

@override
Future<void> resumePreview() async {}

@override
Future<void> setDescription(CameraDescription description) async {}

@override
CameraDescription get description => value.description;
}

void main() {
Expand Down
54 changes: 32 additions & 22 deletions packages/camera/camera/test/camera_value_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand All @@ -32,6 +34,7 @@ void main() {
recordingOrientation: DeviceOrientation.portraitUp,
focusPointSupported: true,
previewPauseOrientation: DeviceOrientation.portraitUp,
description: FakeController.fakeDescription,
);

expect(cameraValue, isA<CameraValue>());
Expand All @@ -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<CameraValue>());
expect(cameraValue.isInitialized, isFalse);
Expand All @@ -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<CameraValue>());
Expand All @@ -99,23 +104,26 @@ 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));

expect(cameraValue.aspectRatio, 2.0);
});

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);
expect(cameraValue.errorDescription, 'error');
});

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,
Expand All @@ -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))');
});
});
}